diff --git a/docs/assets/dashboard-bot-config-focus/desktop.png b/docs/assets/dashboard-bot-config-focus/desktop.png new file mode 100644 index 000000000..bd294e4f0 Binary files /dev/null and b/docs/assets/dashboard-bot-config-focus/desktop.png differ diff --git a/docs/assets/dashboard-bot-config-focus/mobile.png b/docs/assets/dashboard-bot-config-focus/mobile.png new file mode 100644 index 000000000..70c6ae703 Binary files /dev/null and b/docs/assets/dashboard-bot-config-focus/mobile.png differ diff --git a/src/core/role-resolver.ts b/src/core/role-resolver.ts index 3597ac38b..d9444a5c2 100644 --- a/src/core/role-resolver.ts +++ b/src/core/role-resolver.ts @@ -237,19 +237,58 @@ function roleMetaFilePath(larkAppId: string, chatId: string): string { return join(config.session.dataDir, 'roles', larkAppId, `${chatId}.meta.json`); } +/** Absolute path to the bot-level default role metadata sidecar (sits next to + * the team role .md, keyed by app only — no chat). */ +function teamRoleMetaFilePath(larkAppId: string): string { + return join(config.session.dataDir, 'team-roles', `${larkAppId}.meta.json`); +} + /** - * Read the injection mode for a (bot, chat). Defaults to 'every' when no - * sidecar exists or it can't be parsed — i.e. legacy behavior is the default. + * Read the bot-level DEFAULT injection mode (applies to any chat that hasn't set + * its own). Defaults to 'every' (legacy) when unset/unparseable. This is the + * fallback consulted by readRoleInjectMode — it lets an operator make the bot's + * default role inject-once across all its chats from the Bot config page, + * without touching each chat individually. + */ +export function readTeamRoleInjectMode(larkAppId: string): RoleInjectMode { + if (!larkAppId) return 'every'; + try { + const fp = teamRoleMetaFilePath(larkAppId); + if (!existsSync(fp)) return 'every'; + const meta = JSON.parse(readFileSync(fp, 'utf-8')) as { inject?: unknown }; + return meta?.inject === 'once' ? 'once' : 'every'; + } catch { + return 'every'; + } +} + +/** Persist the bot-level default injection mode. 'every' removes the sidecar. */ +export function writeTeamRoleInjectMode(larkAppId: string, mode: RoleInjectMode): void { + const fp = teamRoleMetaFilePath(larkAppId); + if (mode === 'once') { + mkdirSync(dirname(fp), { recursive: true }); + atomicWriteFileSync(fp, JSON.stringify({ inject: 'once' })); + } else { + try { unlinkSync(fp); } catch { /* already absent */ } + } + logger.info(`[role] team inject mode app=${larkAppId} => ${mode}`); +} + +/** + * Read the injection mode for a (bot, chat). A chat that set its own mode via + * 角色管理 wins (sidecar present ⇒ 'once'); otherwise we fall back to the + * bot-level default (readTeamRoleInjectMode), which itself defaults to 'every' + * — so legacy behavior is unchanged until an operator opts a bot into 'once'. */ export function readRoleInjectMode(larkAppId: string, chatId: string): RoleInjectMode { if (!larkAppId || !chatId || !isValidRoleChatId(chatId)) return 'every'; try { const fp = roleMetaFilePath(larkAppId, chatId); - if (!existsSync(fp)) return 'every'; + if (!existsSync(fp)) return readTeamRoleInjectMode(larkAppId); const meta = JSON.parse(readFileSync(fp, 'utf-8')) as { inject?: unknown }; return meta?.inject === 'once' ? 'once' : 'every'; } catch { - return 'every'; + return readTeamRoleInjectMode(larkAppId); } } diff --git a/src/dashboard/federation-spoke-api.ts b/src/dashboard/federation-spoke-api.ts index 2017e930b..f85af453f 100644 --- a/src/dashboard/federation-spoke-api.ts +++ b/src/dashboard/federation-spoke-api.ts @@ -30,6 +30,7 @@ import { createInvite, deleteInvitesForTeam } from '../services/invite-store.js' import { removeTeamFederation, removeDeployment } from '../services/federation-store.js'; import { loadBotConfigs, registerBot, getBot, type BotConfig } from '../bot-registry.js'; import { setBotCapability, clearBotCapability } from '../services/bot-profile-store.js'; +import { readTeamRoleInjectMode, writeTeamRoleInjectMode, type RoleInjectMode } from '../core/role-resolver.js'; import { setBotOwner } from '../services/bot-owner-store.js'; import { setDeploymentOwner } from '../services/deployment-identity.js'; import { createPairing, getPairingStatus, consumePairing } from '../services/pairing-store.js'; @@ -349,7 +350,11 @@ export async function handleFederationSpokeApi( if (!localIds.has(larkAppId)) { jsonRes(res, 404, { ok: false, error: 'not_a_local_bot' }); return true; } if (field === 'role' && method === 'GET') { const fp = teamRolePath(dataDir, larkAppId); - jsonRes(res, 200, { ok: true, role: existsSync(fp) ? readFileSync(fp, 'utf-8') : '' }); + jsonRes(res, 200, { + ok: true, + role: existsSync(fp) ? readFileSync(fp, 'utf-8') : '', + injectMode: readTeamRoleInjectMode(larkAppId), + }); return true; } if (method === 'PUT') { @@ -362,8 +367,13 @@ export async function handleFederationSpokeApi( const role = String(body?.role ?? '').trim(); if (role) writeTeamRole(dataDir, larkAppId, role); else { try { unlinkSync(teamRolePath(dataDir, larkAppId)); } catch { /* already gone */ } } + // Bot-level default injection mode travels with the team role. Only + // persist when the caller sends it (older clients omit the field). + if (body?.injectMode === 'once' || body?.injectMode === 'every') { + writeTeamRoleInjectMode(larkAppId, body.injectMode as RoleInjectMode); + } } - jsonRes(res, 200, { ok: true }); + jsonRes(res, 200, { ok: true, injectMode: readTeamRoleInjectMode(larkAppId) }); return true; } jsonRes(res, 405, { ok: false, error: 'method_not_allowed' }); diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 767a9ddd1..46f69090a 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -21,12 +21,14 @@ import { import { mountReactPage, type PageDisposer } from './react-mount.js'; import { useT } from './react-hooks.js'; import { store } from './store.js'; +import type { RoleInjectMode } from './roles.js'; import { CreateActionButton, DropdownMenu, Html, InfoTip as BaseInfoTip, LoadingState, + OverflowText, RefreshIconButton, dropdownLabel, } from './dashboard-components.js'; @@ -57,6 +59,181 @@ type BotProfileRoleState = { items: BotProfileRoleItem[]; }; +export type BotDefaultsTab = 'common' | 'sessions' | 'security' | 'cards' | 'advanced'; + +export const BOT_DEFAULTS_TABS: readonly BotDefaultsTab[] = [ + 'common', + 'sessions', + 'security', + 'cards', + 'advanced', +]; + +export function BotDefaultsTabs(props: { + active: BotDefaultsTab; + onChange(tab: BotDefaultsTab): void; +}) { + const tr = useT(); + const tabRefs = useRef>([]); + const labels: Record = { + common: tr('botDefaults.tabCommon'), + sessions: tr('botDefaults.tabSessions'), + security: tr('botDefaults.tabSecurity'), + cards: tr('botDefaults.tabCards'), + advanced: tr('botDefaults.tabAdvanced'), + }; + + function selectAt(index: number): void { + const nextIndex = (index + BOT_DEFAULTS_TABS.length) % BOT_DEFAULTS_TABS.length; + const next = BOT_DEFAULTS_TABS[nextIndex]!; + props.onChange(next); + tabRefs.current[nextIndex]?.focus(); + } + + return ( + + ); +} + +// Two-column waterfall (masonry) for the task panels. A plain row-major grid +// locks each row to its tallest tile, stranding a short tile beside a tall one +// with a dead gap below. This lays tiles out by greedily dropping each into the +// currently shortest column and writing back an inline grid-column / +// grid-row-start over the CSS 1px row track. Tiles stay direct grid children — +// never reparented into per-column wrappers — so their unsaved form drafts +// (the whole point of the focused editor) never remount. Degrades to the plain +// auto-fill grid when there is only one column (mobile / narrow) or before the +// first measure. +const BD_GRID_ROW_PX = 1; // must match grid-auto-rows in style.css +const BD_GRID_GAP_PX = 14; // must match .bd-tab-grid gap + +export function BdTabGrid(props: { children: ReactNode; className?: string }) { + const ref = useRef(null); + + useEffect(() => { + const grid = ref.current; + if (!grid || typeof window === 'undefined') return undefined; + + const clearPlacement = (tiles: HTMLElement[]) => { + for (const tile of tiles) { + tile.style.gridColumn = ''; + tile.style.gridRowStart = ''; + tile.style.gridRowEnd = ''; + } + }; + + const layout = () => { + const tiles = Array.from(grid.children).filter( + (n): n is HTMLElement => n instanceof HTMLElement, + ); + if (!tiles.length) return; + + // A hidden panel (display:none) reports 0 width — skip; the ResizeObserver + // re-fires with real geometry the moment the tab becomes visible. + const gridWidth = grid.clientWidth; + if (gridWidth <= 0) return; + + // Decide the column count from the SAME width the CSS @container rule keys + // off (the .bd-detail container), instead of parsing + // getComputedStyle().gridTemplateColumns — that value contains spaces + // inside minmax(...) and, once we write an inline grid-column, can report a + // stale/implicit extra track, which previously produced a rogue 3rd column. + // Reading the container keeps JS placement and the CSS track count in lockstep. + const container = grid.closest('.bd-detail'); + const decideWidth = container?.clientWidth ?? gridWidth; + const columns = decideWidth >= 1024 ? 2 : 1; + + // Single column (mobile / narrow): normal flow already stacks with no gap. + if (columns < 2) { clearPlacement(tiles); return; } + + const rowStep = BD_GRID_ROW_PX + BD_GRID_GAP_PX; + const colBottom = new Array(columns).fill(0); // running bottom, row units + + for (const tile of tiles) { + const spanRows = Math.max( + 1, + Math.ceil((tile.getBoundingClientRect().height + BD_GRID_GAP_PX) / rowStep), + ); + if (tile.classList.contains('bd-tile-wide')) { + // full-width tile: start below the tallest column, then level every + // column to its bottom so following tiles pack beneath it evenly. + const start = Math.max(...colBottom); + tile.style.gridColumn = '1 / -1'; + tile.style.gridRowStart = String(start + 1); + tile.style.gridRowEnd = String(start + 1 + spanRows); + colBottom.fill(start + spanRows); + continue; + } + // drop into the currently shortest column (true waterfall) + let target = 0; + for (let c = 1; c < columns; c++) if (colBottom[c]! < colBottom[target]!) target = c; + const start = colBottom[target]!; + tile.style.gridColumn = String(target + 1); + tile.style.gridRowStart = String(start + 1); + tile.style.gridRowEnd = String(start + 1 + spanRows); + colBottom[target] = start + spanRows; + } + }; + + // Measure after paint; re-run on any tile resize (content toggles, textarea + // growth, async loads, tab becoming visible) and on viewport resize. + const raf = window.requestAnimationFrame(layout); + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => layout()) : null; + if (ro) { + ro.observe(grid); + for (const child of Array.from(grid.children)) ro.observe(child); + } + window.addEventListener('resize', layout); + return () => { + window.cancelAnimationFrame(raf); + ro?.disconnect(); + window.removeEventListener('resize', layout); + }; + }); + + return ( +
+ {props.children} +
+ ); +} + function statusClass(status: StatusMessage, extra = ''): string { const suffix = status ? ` ${status.ok ? 'hint-ok' : 'hint-warn-inline'}` : ''; return `oncall-status${extra ? ` ${extra}` : ''}${suffix}`; @@ -287,6 +464,7 @@ export function BotDefaultsPage() { const [profileRoleVersion, setProfileRoleVersion] = useState(0); const [, setAvatarVersion] = useState(0); const [onboardingBusy, setOnboardingBusy] = useState(false); + const [activeTab, setActiveTab] = useState('common'); const refresh = useCallback(async (clearProfileRoles = false) => { if (clearProfileRoles) setProfileRoleVersion(version => version + 1); @@ -381,6 +559,8 @@ export function BotDefaultsPage() { bot={selectedBot} cliState={cliState} patchBot={patchBot} + activeTab={activeTab} + onTabChange={setActiveTab} /> ); } else { @@ -421,6 +601,12 @@ export function BotDefaultsPage() { onChange={event => setQuery(event.currentTarget.value)} /> +
+ {tr('botDefaults.rosterCount', { count: filtered.length })} + {query.trim() && filtered.length !== bots.length ? ( + {tr('botDefaults.rosterFiltered', { total: bots.length })} + ) : null} +
{!loadError && filtered.map(bot => (
- {name} + {cli || bot.larkAppId.slice(0, 14)}
{bot.defaultOncall?.enabled ? oncall : null} @@ -466,7 +652,13 @@ function RosterItem(props: { bot: BotDefaultsRow; selected: boolean; onSelect(): ); } -function BotDefaultsCard(props: { bot: BotDefaultsRow; cliState: CliOptionsState; patchBot: PatchBot }) { +function BotDefaultsCard(props: { + bot: BotDefaultsRow; + cliState: CliOptionsState; + patchBot: PatchBot; + activeTab: BotDefaultsTab; + onTabChange(tab: BotDefaultsTab): void; +}) { const tr = useT(); const { bot, cliState, patchBot } = props; const name = bot.botName ?? bot.larkAppId; @@ -499,55 +691,113 @@ function BotDefaultsCard(props: { bot: BotDefaultsRow; cliState: CliOptionsState return (
-
- -
- - ● {tr('botDefaults.metaOnline')} - {tr('botDefaults.lastEnabled')}: {fmtSince(def.since ?? 0)} - {tr('botDefaults.autobound', { count: bot.autoboundChatCount ?? 0 })} - - )} - /> +
+
+ +
+ + ● {tr('botDefaults.metaOnline')} + {(def.since ?? 0) > 0 ? {tr('botDefaults.lastEnabled')}: {fmtSince(def.since ?? 0)} : null} + {(bot.autoboundChatCount ?? 0) > 0 ? {tr('botDefaults.autobound', { count: bot.autoboundChatCount ?? 0 })} : null} + + )} + /> +
+
+ +
+
+ + -
-
-
-
- - + + +
-
- -
-
-
-
-
-
- - - - -
-
- - - - -
-
+ {bot.cliId !== 'riff' ? ( +
+ ) : null} + {/* Codex App 历史显示只对 codex-app agent 有意义(其它 CLI 无此渲染通道), + 选了别的 agent 就隐藏,避免无效开关。 */} + {bot.cliId === 'codex-app' ? ( +
+ ) : null} +
+
@@ -559,7 +809,6 @@ function RuntimeEnvironmentSection(props: { bot: BotDefaultsRow; patchBot: Patch return (

{tr('botDefaults.sectionRuntimeEnv')}

-
@@ -1734,6 +1983,7 @@ function RoleSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { const { bot, patchBot } = props; const [loaded, setLoaded] = useState(typeof bot.teamRole === 'string'); const [role, setRole] = useState(typeof bot.teamRole === 'string' ? bot.teamRole : ''); + const [injectMode, setInjectMode] = useState('every'); const [status, setStatus] = useState(null); const [busy, setBusy] = useState(false); @@ -1759,6 +2009,7 @@ function RoleSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { if (r.ok && body.ok) { const next = body.role ?? ''; setRole(next); + setInjectMode(body.injectMode === 'once' ? 'once' : 'every'); setLoaded(true); patchBot(bot.larkAppId, { teamRole: next }); } else { @@ -1771,15 +2022,31 @@ function RoleSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { return () => { active = false; }; }, [bot.larkAppId, bot.teamRole, patchBot, tr]); - async function putRole(nextRole: string, deleted: boolean): Promise { + // injectMode isn't cached on the bot row, so when the team role is already + // resolved (cache hit above skips the GET) fetch just the mode once per bot. + useEffect(() => { + let active = true; + if (typeof bot.teamRole !== 'string') return () => { active = false; }; + void (async () => { + try { + const r = await fetch(`/api/team/local-bots/${encodeURIComponent(bot.larkAppId)}/role`); + const body = await r.json().catch(() => ({})); + if (active && r.ok && body.ok) setInjectMode(body.injectMode === 'once' ? 'once' : 'every'); + } catch { /* keep default 'every' */ } + })(); + return () => { active = false; }; + }, [bot.larkAppId]); + + async function putRole(nextRole: string, deleted: boolean, mode: RoleInjectMode = injectMode): Promise { if (!loaded) return; setStatus(null); setBusy(true); try { - const res = await sendJson('PUT', `/api/team/local-bots/${encodeURIComponent(bot.larkAppId)}/role`, { role: nextRole }); + const res = await sendJson('PUT', `/api/team/local-bots/${encodeURIComponent(bot.larkAppId)}/role`, { role: nextRole, injectMode: mode }); if (res.ok && res.body.ok) { const stored = nextRole.trim(); setRole(stored); + if (res.body.injectMode === 'once' || res.body.injectMode === 'every') setInjectMode(res.body.injectMode); patchBot(bot.larkAppId, { teamRole: stored }); setStatus({ text: `✓ ${deleted ? tr('botDefaults.roleDeleted') : tr('botDefaults.roleSaved')}`, ok: true }); } else { @@ -1792,6 +2059,11 @@ function RoleSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { } } + const injectOptions: Array<{ value: RoleInjectMode; label: string }> = [ + { value: 'every', label: tr('roles.injectModeEvery') }, + { value: 'once', label: tr('roles.injectModeOnce') }, + ]; + return (

{tr('botDefaults.sectionRole')}

@@ -1803,6 +2075,19 @@ function RoleSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { value={role} onChange={event => setRole(event.currentTarget.value)} /> +
+ {tr('roles.injectModeLabel')} + + id={`bd-role-inject-${bot.larkAppId}`} + className="bd-role-inject-menu" + ariaLabel={tr('roles.injectModeLabel')} + disabled={!loaded || busy} + label={dropdownLabel(injectOptions, injectMode)} + value={injectMode} + options={injectOptions} + onChange={mode => { const next = mode === 'once' ? 'once' : 'every'; setInjectMode(next); void putRole(role, role.trim() === '', next); }} + /> +
diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 4aaaa2914..8f5562260 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1648,6 +1648,15 @@ const zh: DashboardMessages = { 'botDefaults.metaOnline': '在线 · daemon 正常', 'botDefaults.search': '搜索 bot 名 / app id', 'botDefaults.refresh': '刷新', + 'botDefaults.rosterCount': '{count} 个机器人', + 'botDefaults.rosterFiltered': '共 {total} 个', + 'botDefaults.tabNavigation': 'Bot 配置分类', + 'botDefaults.tabCommon': '常用', + 'botDefaults.tabSessions': '会话', + 'botDefaults.tabSecurity': '权限与安全', + 'botDefaults.tabCards': '消息卡片', + 'botDefaults.tabAdvanced': '高级', + 'botDefaults.tabHint': '切换分类不会丢失未保存输入', 'botDefaults.sectionOncall': '新群 Oncall', 'botDefaults.sectionBrand': '卡片签名', 'botDefaults.renameTitle': '修改机器人名称', @@ -3650,6 +3659,15 @@ const en: DashboardMessages = { 'botDefaults.metaOnline': 'Online · daemon healthy', 'botDefaults.search': 'Search bot name / app id', 'botDefaults.refresh': 'Refresh', + 'botDefaults.rosterCount': '{count} bots', + 'botDefaults.rosterFiltered': '{total} total', + 'botDefaults.tabNavigation': 'Bot configuration categories', + 'botDefaults.tabCommon': 'Common', + 'botDefaults.tabSessions': 'Sessions', + 'botDefaults.tabSecurity': 'Security', + 'botDefaults.tabCards': 'Message cards', + 'botDefaults.tabAdvanced': 'Advanced', + 'botDefaults.tabHint': 'Switching categories keeps unsaved input', 'botDefaults.sectionOncall': 'New-chat Oncall', 'botDefaults.sectionBrand': 'Card Signature', 'botDefaults.renameTitle': 'Rename bot', diff --git a/src/dashboard/web/style.css b/src/dashboard/web/style.css index a8c18dae1..de6052ea4 100644 --- a/src/dashboard/web/style.css +++ b/src/dashboard/web/style.css @@ -9618,7 +9618,8 @@ button.contrast:hover { background: var(--danger-soft); border-color: var(--dang box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); } .bd-roster-tx { min-width: 0; flex: 1; } -.bd-roster-tx b { display: block; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.bd-roster-tx b { display: block; font-size: 13px; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.bd-roster-tx b .ui-overflow-text { display: block; font-weight: 600; } .bd-roster-tx span { display: block; color: var(--faint); font-size: 10.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .bd-roster-flag { flex: none; @@ -9919,20 +9920,6 @@ button.contrast:hover { background: var(--danger-soft); border-color: var(--dang color: var(--success); } -/* 配置 tile 两栏 */ -.bd-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - align-items: start; - margin-top: 14px; -} -.bd-column { - display: grid; - align-content: start; - gap: 14px; - min-width: 0; -} .bd-tile { border-radius: var(--radius-xl); border: 1px solid var(--border-soft); @@ -11923,7 +11910,6 @@ button.contrast:hover { background: var(--danger-soft); border-color: var(--dang .bd-layout { grid-template-columns: 1fr; } .bd-roster { position: static; display: flex; overflow-x: auto; } .bd-roster-item { flex: 0 0 auto; } - .bd-grid { grid-template-columns: 1fr; } .bd-profile-head { grid-template-columns: auto minmax(0, 1fr); } .bd-profile-meta { grid-column: 1 / -1; @@ -21578,6 +21564,15 @@ dialog select[multiple]:hover:not(:disabled) { box-shadow: none; } +.bot-defaults-page .bd-layout { + grid-template-columns: 232px minmax(0, 1fr); +} + +.bot-defaults-page .bd-detail { + min-width: 0; + container-type: inline-size; +} + :root[data-theme="dark"] .bot-defaults-page .bd-card.bd-profile { background: transparent; border-color: transparent; @@ -21585,12 +21580,263 @@ dialog select[multiple]:hover:not(:disabled) { background-image: none; } +.bot-defaults-page .bd-profile-chrome { + position: sticky; + top: 0; + z-index: 40; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: var(--radius-xl); + background: var(--surface); + box-shadow: var(--shadow); +} + .bot-defaults-page .bd-profile-head { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; - gap: 14px; + gap: 12px; margin-bottom: 0; + padding: 12px 14px; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +:root[data-theme="dark"] .bot-defaults-page .bd-profile-head { + border-color: transparent; + background: transparent; + backdrop-filter: none; +} + +.bot-defaults-page .bd-profile-avatar { + width: 50px; + height: 50px; +} + +.bot-defaults-page .bd-profile-head .orb-avatar { + width: 42px; + height: 42px; +} + +.bot-defaults-page .bd-profile-title-row { + min-height: 28px; + padding-right: 42px; +} + +.bot-defaults-page .bd-profile-title-content > strong { + min-height: 24px; + font-size: 16px; +} + +.bot-defaults-page .bd-profile-id .mate-role, +.bot-defaults-page .bd-profile-meta small { + min-height: 24px; + padding: 0 9px; + font-size: 10.5px; +} + +.bot-defaults-page .bd-name-edit { + width: 28px; + min-width: 28px; + height: 28px; + min-height: 28px; + padding: 0; + overflow: hidden; + /* icon-only button: hide the text label, render ✎ via ::before centered */ + font-size: 0; + line-height: 0; +} + +.bot-defaults-page .bd-name-edit::before { + content: "✎"; + display: block; + font-size: 14px; + line-height: 1; +} + +.bot-defaults-page .bd-tab-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + min-height: 44px; + padding: 0 12px; + border-top: 1px solid var(--border-soft); + background: var(--surface-muted); +} + +.bot-defaults-page .bd-tabs { + display: flex; + align-items: center; + align-self: stretch; + gap: 2px; + min-width: 0; +} + +.bot-defaults-page .bd-tab { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: stretch; + min-width: 0; + padding: 0 11px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; +} + +.bot-defaults-page .bd-tab::after { + position: absolute; + right: 9px; + bottom: -1px; + left: 9px; + height: 2px; + border-radius: 2px 2px 0 0; + background: transparent; + content: ""; + transform: scaleX(0.55); + transition: background-color 150ms ease, transform 180ms ease; +} + +.bot-defaults-page .bd-tab:hover { + color: var(--fg); +} + +.bot-defaults-page .bd-tab.active { + color: var(--accent-strong); +} + +.bot-defaults-page .bd-tab.active::after { + background: var(--accent); + transform: scaleX(1); +} + +.bot-defaults-page .bd-tab:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 66%, transparent); + outline-offset: -4px; +} + +.bot-defaults-page .bd-tab-hint { + flex: none; + color: var(--faint); + font-size: 10.5px; + line-height: 1.3; + white-space: nowrap; +} + +.bot-defaults-page .bd-tab-panels { + margin-top: 14px; +} + +.bot-defaults-page .bd-tab-panel[hidden] { + display: none; +} + +.bot-defaults-page .bd-tab-panel:not([hidden]) { + animation: bd-tab-enter 180ms ease-out both; +} + +@keyframes bd-tab-enter { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + .bot-defaults-page .bd-tab-panel:not([hidden]) { + animation: none; + } + + .bot-defaults-page .bd-tab::after { + transition: none; + } +} + +.bot-defaults-page .bd-tab-grid { + /* Two-column waterfall (masonry). A plain row-major grid locks each row to + the tallest tile, so a short tile beside a tall one leaves a dead vertical + gap below it. At ≥2 columns the JS in the BdTabGrid component measures every + tile and greedily assigns each to the currently shortest column via inline + grid-column / grid-row over a fine 1px row track, so a short tile packs + directly under its neighbour. Tiles stay direct grid children (never + reparented into per-column wrappers) so their unsaved form drafts survive. + Default is a single column with normal auto rows (no 1px track, or + auto-placed tiles would collapse onto 1px rows and overlap); the fine row + track is only switched on inside the 2-column container query below. */ + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-auto-rows: auto; + gap: 14px; + align-items: start; +} + +/* Two columns only once the detail pane is wide enough that each column stays + comfortable (~500px); below that, one column — avoids the cramped narrow + cards. Threshold matches the column-count decision in the BdTabGrid JS so CSS + tracks and JS placement never disagree. Uses the .bd-detail container query + (container-type: inline-size). The 1px auto-row track that the JS masonry + spans over is scoped here so the single-column fallback keeps auto rows. */ +@container (min-width: 1024px) { + .bot-defaults-page .bd-tab-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1px; + } +} + +.bot-defaults-page .bd-tab-grid > .bd-tile-wide { + /* full-bleed tile (e.g. sandbox paths) spans every column */ + grid-column: 1 / -1; +} + +.bot-defaults-page .bd-tab-grid > .bd-tile { + min-width: 0; + border-radius: var(--radius-xl); + background: var(--surface); + box-shadow: none; +} + +:root[data-theme="dark"] .bot-defaults-page .bd-tab-grid > .bd-tile { + border-color: var(--border-soft); + background: var(--surface); + backdrop-filter: none; +} + +.bot-defaults-page .bd-tab-grid .bd-section-title::before, +.bot-defaults-page .bd-tab-grid .bd-section > .bd-section-title::before, +.bot-defaults-page .bd-tab-grid .bd-subsection > .bd-subsection-title::before { + display: none; +} + +.bot-defaults-page .bd-tab-grid :where(.bd-section-title, .bd-subsection-title) { + gap: 6px; + font-weight: 650; +} + +.bot-defaults-page .bd-tab-grid .bd-section-title { + font-size: 13px; +} + +.bot-defaults-page .bd-tab-grid .bd-subsection-title { + font-size: 12px; +} + +/* A .bd-subsection carries a dashed top border to divide it from a sibling + subsection WITHIN a section. When it is promoted to be a tile's own content + (e.g. 会话常驻上限 / 启动命令 as standalone tiles), that top divider is + spurious — the tile border already frames it. Drop it. */ +.bot-defaults-page .bd-tab-grid > .bd-tile > .bd-subsection:first-child { + margin-top: 0; + padding-top: 0; + border-top: 0; } .bot-defaults-page .bd-profile-head > .bd-profile-meta { @@ -21609,6 +21855,18 @@ dialog select[multiple]:hover:not(:disabled) { margin-bottom: 5px; } +.bot-defaults-page .bd-roster-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 24px; + padding: 2px 5px 3px; + color: var(--faint); + font-size: 10.5px; + font-weight: 600; +} + .bot-defaults-page #bd-filters > input[type="search"] { box-sizing: border-box; flex: 1 1 auto; @@ -21620,7 +21878,7 @@ dialog select[multiple]:hover:not(:disabled) { max-height: var(--button-height); padding: 0 var(--button-padding-x); border: 1px solid var(--border-soft); - border-radius: var(--radius-full); + border-radius: var(--radius-md); background: var(--surface); color: var(--fg); font-size: 12px; @@ -21645,20 +21903,51 @@ dialog select[multiple]:hover:not(:disabled) { } @media (max-width: 980px) { + .bot-defaults-page .bd-layout { + grid-template-columns: 1fr; + } + .bot-defaults-page .bd-roster { display: grid; - overflow: visible; + grid-template-rows: auto auto minmax(0, 1fr); + max-height: min(340px, 42vh); + overflow: hidden; } .bot-defaults-page .bd-roster-list { - display: flex; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 3px; - overflow-x: auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; padding-bottom: 2px; + overscroll-behavior: contain; } .bot-defaults-page .bd-roster-list .bd-roster-item { + min-width: 0; + } + + .bot-defaults-page .bd-profile-chrome { + position: static; + } + + .bot-defaults-page .bd-tab-bar { + align-items: stretch; + padding-right: 0; + padding-left: 0; + overflow-x: auto; + overscroll-behavior-x: contain; + } + + .bot-defaults-page .bd-tabs { flex: 0 0 auto; + padding: 0 6px; + } + + .bot-defaults-page .bd-tab-hint { + display: none; } } @@ -21795,6 +22084,15 @@ dialog select[multiple]:hover:not(:disabled) { margin: 12px 0 0; } +/* .bd-row (display:grid) and .toggle-row (display:flex) both override the UA + [hidden] { display:none }, so rows the React code marks hidden (e.g. the + working-dir 目录 input in repo-picker "off" mode, or the auto-worktree toggle + outside "default" mode) stayed visible. Restore [hidden] for these rows. */ +.bot-defaults-page .bd-body .bd-row[hidden], +.bot-defaults-page .bd-body .toggle-row[hidden] { + display: none; +} + .bot-defaults-page .bd-body .bd-row + .bd-row, .bot-defaults-page .bd-body .toggle-row + .bd-row, .bot-defaults-page .bd-body .bd-row + .toggle-row { @@ -21805,7 +22103,7 @@ dialog select[multiple]:hover:not(:disabled) { margin: 0; color: var(--muted); font-size: 12px; - font-weight: 700; + font-weight: 600; line-height: 1.35; } @@ -21912,6 +22210,24 @@ dialog select[multiple]:hover:not(:disabled) { border-top: 0; } +/* Default-role injection mode selector (once / every) — a compact labelled row + between the role textarea and its save button. */ +.bot-defaults-page .bd-role-inject { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.bot-defaults-page .bd-role-inject > .bd-subsection-title { + margin: 0; +} + +.bot-defaults-page .bd-role-inject-menu { + min-width: 148px; +} + .bot-defaults-page .bd-tile :where(input[type="text"], input[type="number"], textarea) { box-sizing: border-box; width: 100%; @@ -21927,9 +22243,9 @@ dialog select[multiple]:hover:not(:disabled) { min-height: var(--button-height); max-height: var(--button-height); padding: 0 var(--button-padding-x); - border-radius: var(--radius-full); + border-radius: var(--radius-md); font-size: 12px; - font-weight: 700; + font-weight: 600; line-height: normal; } @@ -21949,11 +22265,11 @@ dialog select[multiple]:hover:not(:disabled) { max-height: var(--button-height); padding: 0 var(--button-padding-x); border: 1px solid var(--border-soft); - border-radius: var(--radius-full); + border-radius: var(--radius-md); background: var(--surface); color: var(--fg); font-size: 12px; - font-weight: 700; + font-weight: 600; line-height: normal; box-shadow: none; outline: none; @@ -22183,6 +22499,7 @@ dialog select[multiple]:hover:not(:disabled) { min-height: var(--button-height); max-height: var(--button-height); box-sizing: border-box; + border-radius: var(--radius-md); line-height: 1; } diff --git a/test/dashboard-bot-defaults-cliid.test.ts b/test/dashboard-bot-defaults-cliid.test.ts index 8b1766f42..5c69637c2 100644 --- a/test/dashboard-bot-defaults-cliid.test.ts +++ b/test/dashboard-bot-defaults-cliid.test.ts @@ -2,11 +2,61 @@ import React from 'react'; import TestRenderer, { act } from 'react-test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { displayCliId } from '../src/dashboard/web/bot-defaults.js'; -import { BotAgentSection, CodexAppDisplaySection } from '../src/dashboard/web/bot-defaults-page.js'; +import { + BOT_DEFAULTS_TABS, + BotAgentSection, + BotDefaultsTabs, + CodexAppDisplaySection, + type BotDefaultsTab, +} from '../src/dashboard/web/bot-defaults-page.js'; import { isOnboardingSubmitDisabled } from '../src/dashboard/web/bot-onboarding.js'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +describe('bot defaults task tabs', () => { + it('renders five accessible categories and supports pointer selection', () => { + const onChange = vi.fn(); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(BotDefaultsTabs, { + active: 'common' satisfies BotDefaultsTab, + onChange, + })); + }); + + const tabs = renderer.root.findAllByProps({ role: 'tab' }); + expect(BOT_DEFAULTS_TABS).toEqual(['common', 'sessions', 'security', 'cards', 'advanced']); + expect(tabs).toHaveLength(5); + expect(tabs[0]!.props['aria-selected']).toBe(true); + expect(tabs[1]!.props.tabIndex).toBe(-1); + + act(() => tabs[2]!.props.onClick()); + expect(onChange).toHaveBeenCalledWith('security'); + }); + + it('wraps arrow-key navigation and supports Home / End', () => { + const onChange = vi.fn(); + let renderer!: TestRenderer.ReactTestRenderer; + act(() => { + renderer = TestRenderer.create(React.createElement(BotDefaultsTabs, { + active: 'common' satisfies BotDefaultsTab, + onChange, + })); + }); + const tabs = renderer.root.findAllByProps({ role: 'tab' }); + const event = (key: string) => ({ key, preventDefault: vi.fn() }); + + act(() => tabs[0]!.props.onKeyDown(event('ArrowLeft'))); + expect(onChange).toHaveBeenLastCalledWith('advanced'); + act(() => tabs[4]!.props.onKeyDown(event('ArrowRight'))); + expect(onChange).toHaveBeenLastCalledWith('common'); + act(() => tabs[2]!.props.onKeyDown(event('Home'))); + expect(onChange).toHaveBeenLastCalledWith('common'); + act(() => tabs[2]!.props.onKeyDown(event('End'))); + expect(onChange).toHaveBeenLastCalledWith('advanced'); + }); +}); + describe('bot defaults cli label', () => { it('prefers /api/bots cliId before session fallback', () => { expect(displayCliId({ larkAppId: 'cli_traex', cliId: 'traex' }, 'codex')).toBe('traex'); diff --git a/test/dashboard-bot-defaults-layout.test.ts b/test/dashboard-bot-defaults-layout.test.ts new file mode 100644 index 000000000..459b6ae03 --- /dev/null +++ b/test/dashboard-bot-defaults-layout.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const page = readFileSync(new URL('../src/dashboard/web/bot-defaults-page.tsx', import.meta.url), 'utf8'); +const css = readFileSync(new URL('../src/dashboard/web/style.css', import.meta.url), 'utf8'); +const i18n = readFileSync(new URL('../src/dashboard/web/i18n.ts', import.meta.url), 'utf8'); + +describe('bot defaults focused layout', () => { + it('keeps every task panel mounted while hiding inactive categories', () => { + for (const tab of ['common', 'sessions', 'security', 'cards', 'advanced']) { + expect(page).toContain(`id="bd-panel-${tab}"`); + expect(page).toContain(`hidden={props.activeTab !== '${tab}'}`); + } + + expect(page).toContain(' { + // A row-major grid locks each row to its tallest tile, leaving dead space + // under a short tile next to a tall one. BdTabGrid measures every tile and + // greedily drops it into the shortest column over a fine 1px row track; + // the wide tile spans all columns. Two columns only above the container + // threshold, else a single auto-row column (no overlap). + expect(page).toContain('function BdTabGrid'); + expect(page).toContain('colBottom'); // shortest-column bookkeeping + // every panel uses the masonry wrapper, none keep a raw grid div + expect(page).not.toContain('
'); + expect((page.match(//g) ?? []).length).toBe(5); + // CSS: single column + auto rows by default, 2 cols + 1px row track in the container query + expect(css).toMatch(/\.bot-defaults-page \.bd-tab-grid\s*\{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\);[\s\S]*?grid-auto-rows:\s*auto;/); + expect(css).toMatch(/@container \(min-width: 1024px\)\s*\{[\s\S]*?\.bot-defaults-page \.bd-tab-grid\s*\{[\s\S]*?grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);[\s\S]*?grid-auto-rows:\s*1px;/); + expect(css).toMatch(/\.bot-defaults-page \.bd-tab-grid > \.bd-tile-wide\s*\{[\s\S]*?grid-column:\s*1 \/ -1;/); + }); + + it('keeps the mobile roster bounded with a real scrollport instead of clipping', () => { + // Grid auto rows keep max-content height, so the list row must be + // forced into the remaining space (minmax(0,1fr) + min-height:0) or + // overflow-y:auto never produces a scrollport and long rosters clip. + expect(css).toMatch(/@media \(max-width: 980px\)[\s\S]*?\.bot-defaults-page \.bd-roster\s*\{[\s\S]*?grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/); + expect(css).toMatch(/@media \(max-width: 980px\)[\s\S]*?\.bot-defaults-page \.bd-roster-list\s*\{[\s\S]*?min-height:\s*0;[\s\S]*?overflow-y:\s*auto;/); + }); + + it('lets long roster names scroll on hover instead of hard-clipping', () => { + expect(page).toMatch(/]*\/><\/b>/); + }); + + it('files each section under its category per 申晗 IA', () => { + const panelStart = (id: string) => page.indexOf(`id="bd-panel-${id}"`); + const common = page.slice(panelStart('common'), panelStart('sessions')); + const sessions = page.slice(panelStart('sessions'), panelStart('security')); + const cards = page.slice(panelStart('cards'), panelStart('advanced')); + const advanced = page.slice(panelStart('advanced')); + + // 会话常驻上限(含机器过载告警) + 启动命令 + /summary 总结范围 live under 会话. + expect(sessions).toContain(' { + for (const key of ['tabCommon', 'tabSessions', 'tabSecurity', 'tabCards', 'tabAdvanced']) { + expect(i18n.match(new RegExp(`'botDefaults\\.${key}'`, 'g'))).toHaveLength(2); + } + }); +}); diff --git a/test/role-resolver.test.ts b/test/role-resolver.test.ts index 224633984..fe3c19243 100644 --- a/test/role-resolver.test.ts +++ b/test/role-resolver.test.ts @@ -95,8 +95,25 @@ describe('role injection mode', () => { expect(resolveRoleInjection('app1', 'oc_r')).toEqual({ content: 'CHAT', source: 'chat', injectMode: 'once' }); }); - it('buildFollowUpContent omits the block when mode is "once", keeps it on "every"', async () => { - await fresh(); + it('falls back to the bot-level default injection mode when a chat has none', async () => { + const { readRoleInjectMode, readTeamRoleInjectMode, writeTeamRoleInjectMode, writeRoleInjectMode } = await fresh(); + // bot-level default itself defaults to 'every' (legacy). + expect(readTeamRoleInjectMode('appB')).toBe('every'); + expect(readRoleInjectMode('appB', 'oc_x')).toBe('every'); + // Opt the whole bot into 'once' → any chat without its own sidecar inherits it. + writeTeamRoleInjectMode('appB', 'once'); + expect(readTeamRoleInjectMode('appB')).toBe('once'); + expect(readRoleInjectMode('appB', 'oc_x')).toBe('once'); + expect(readRoleInjectMode('appB', 'oc_y')).toBe('once'); + // A per-chat sidecar still wins over the bot default. + writeRoleInjectMode('appB', 'oc_x', 'once'); // explicit once (same value) + expect(readRoleInjectMode('appB', 'oc_x')).toBe('once'); + // Clearing the bot default returns unset chats to 'every'. + writeTeamRoleInjectMode('appB', 'every'); // removes the meta sidecar + expect(readRoleInjectMode('appB', 'oc_y')).toBe('every'); + }); + + it('buildFollowUpContent omits the block when mode is "once", keeps it on "every"', async () => { await fresh(); const { writeRoleFile, writeRoleInjectMode } = await import('../src/core/role-resolver.js'); writeRoleFile('app1', 'oc_once', 'ONCE_PERSONA'); const { buildNewTopicPrompt, buildFollowUpContent } = await import('../src/core/session-manager.js');