diff --git a/changelog.d/1766-settings-projects.added.md b/changelog.d/1766-settings-projects.added.md new file mode 100644 index 000000000..1decc41ee --- /dev/null +++ b/changelog.d/1766-settings-projects.added.md @@ -0,0 +1 @@ +Settings has a **Projects** page beside Bots and group chats ([#1766](https://github.com/ThinkFlowLab/FlyBot/issues/1766)): create a Project with a name and an optional working folder chosen from the folder picker, open its board from the row, and bind or unbind a Bot or a group. A Project-less board now offers the page that creates one instead of a dead end. diff --git a/core/client.js b/core/client.js index 2f0a32dd9..a4f6362cb 100644 --- a/core/client.js +++ b/core/client.js @@ -1365,7 +1365,21 @@ var ZH = { // The Project board names its columns with the same keys the task record uses. "not-started": "未开始", "in-progress": "进行中", - "done": "已完成" + "done": "已完成", + "Projects": "项目", + "New Project": "新建项目", + "Project name": "项目名称", + "e.g. Website": "例如:官网", + "Optional. Where this Project's work happens.": "可选。这个项目的工作目录。", + "Projects · {count}": "项目 · {count}", + "No Project yet — create one.": "还没有项目——新建一个。", + "Bind a Bot or group": "绑定 Bot 或群聊", + "Bind to {name}": "绑定到 {name}", + "Open board": "打开看板", + "Unbind": "解除绑定", + "Bindings": "绑定关系", + "No cards yet.": "还没有卡片。", + "Open Projects in Settings": "在设置中打开项目" }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], @@ -17071,8 +17085,8 @@ var ABOUT_CSS = ` // src/client/settings.js var import_react40 = require("react"); var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1); -var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; -var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; +var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; +var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; function SetSection({ title, children }) { return (0, import_react40.createElement)("section", { className: "bt-set-section" }, (0, import_react40.createElement)("h3", null, title), (0, import_react40.createElement)("div", { className: "bt-set-card" }, children)); } @@ -17361,6 +17375,7 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false shown === "bots" && !bot ? (0, import_react40.createElement)(BotsPage, { roster, actions, run }) : null, room2 ? (0, import_react40.createElement)("div", { className: "bt-settings-form" }, (0, import_react40.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, shown === "groups" && !room2 ? (0, import_react40.createElement)(GroupsPage, { roster, actions, run }) : null, + shown === "projects" ? (0, import_react40.createElement)(ProjectsPage, { roster, actions, run, call: call2 }) : null, shown === "connectors" ? (0, import_react40.createElement)(ConnectorsPage, { roster, actions }) : null, shown === "workflows" ? (0, import_react40.createElement)(WorkflowsPage, { call: call2, roster }) : null, pluginPage ? (0, import_react40.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, @@ -17381,6 +17396,142 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false ) ); } +var openProjectBoard = (id) => { + if (typeof window !== "undefined") window.location.hash = `#project=${id}`; +}; +var projectMembers = (roster) => [ + ...(roster?.bots ?? []).filter((bot) => !bot.remote).map((bot) => ({ kind: "bot", id: bot.id, name: bot.name, projectId: bot.projectId ?? null })), + ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null })) +]; +function ProjectsList({ projects, roster, call: call4, reload }) { + const members = projectMembers(roster); + const bind = async (choice, project) => { + const [kind, id] = String(choice ?? "").split(":"); + if (!kind || !id) return; + await call4("project-bind", { kind, id, projectId: project.id }); + await reload?.(); + }; + const unbind = async (member) => { + await call4("project-bind", { kind: member.kind, id: member.id, projectId: null }); + await reload?.(); + }; + const bound2 = members.filter((member) => member.projectId !== null && member.projectId !== void 0); + return [ + (0, import_react40.createElement)( + SetSection, + { key: "projects", title: t("Projects · {count}", { count: projects.length }) }, + projects.length === 0 ? (0, import_react40.createElement)("div", { className: "bt-set-row" }, (0, import_react40.createElement)("div", { className: "bt-set-copy" }, (0, import_react40.createElement)("span", null, t("No Project yet — create one.")))) : projects.map((project) => (0, import_react40.createElement)( + SetRow, + { + key: project.id, + label: project.name, + hint: project.workDir === null || project.workDir === void 0 ? void 0 : project.workDir + }, + (0, import_react40.createElement)( + "div", + { className: "bt-project-row-controls" }, + (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")), + (0, import_react40.createElement)( + "select", + { + className: "bt-select", + "aria-label": t("Bind to {name}", { name: project.name }), + value: "", + onChange: (event) => { + void bind(event.target.value, project); + } + }, + (0, import_react40.createElement)("option", { value: "" }, t("Bind a Bot or group")), + members.map((member) => (0, import_react40.createElement)( + "option", + { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` }, + `${member.kind === "group" ? t("Group") : t("Bot")} · ${member.name}` + )) + ) + ) + )) + ), + bound2.length === 0 ? null : (0, import_react40.createElement)( + SetSection, + { key: "bindings", title: t("Bindings") }, + bound2.map((member) => (0, import_react40.createElement)( + SetRow, + { + key: `${member.kind}:${member.id}`, + label: member.name, + hint: member.kind === "group" ? t("Group") : t("Bot") + }, + (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { + void unbind(member); + } }, t("Unbind")) + )) + ) + ]; +} +function ProjectsPage({ roster, actions, run, call: call4 }) { + const [state, setState] = (0, import_react40.useState)({ projects: [], error: null }); + const [name, setName] = (0, import_react40.useState)(""); + const [workDir, setWorkDir] = (0, import_react40.useState)(null); + const reload = (0, import_react40.useCallback)(async () => { + const listing = await call4("projects"); + setState({ projects: listing?.projects ?? [], error: null }); + }, [call4]); + (0, import_react40.useEffect)(() => { + void reload().catch((error) => setState({ projects: [], error: error?.message ?? String(error) })); + }, [reload]); + const create = async () => { + const trimmed3 = name.trim(); + if (trimmed3 === "") return; + const body = { name: trimmed3 }; + if (workDir !== null && workDir !== "") body.workDir = workDir; + try { + await call4("project-create", body); + setName(""); + setWorkDir(null); + await reload(); + } catch (error) { + setState({ projects: state.projects, error: error?.message ?? String(error) }); + } + }; + return [ + state.error === null ? null : (0, import_react40.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error), + (0, import_react40.createElement)( + SetSection, + { key: "new", title: t("New Project") }, + (0, import_react40.createElement)( + SetRow, + { label: t("Name") }, + (0, import_react40.createElement)("input", { + className: "bt-input", + "aria-label": t("Project name"), + value: name, + placeholder: t("e.g. Website"), + onChange: (event) => setName(event.target.value) + }) + ), + (0, import_react40.createElement)( + SetRow, + { label: t("Working folder"), hint: t("Optional. Where this Project's work happens.") }, + (0, import_react40.createElement)(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir }) + ), + (0, import_react40.createElement)( + "div", + { className: "bt-actions" }, + (0, import_react40.createElement)("button", { + type: "button", + className: "bt-primary bt-with-icon", + disabled: name.trim() === "", + onClick: () => { + void create(); + } + }, (0, import_react40.createElement)(PlusIcon), t("New Project")) + ) + ), + // The list is rendered here rather than as a child element: it holds no state of its own, + // and the page keeps one reload path for both halves. + ...ProjectsList({ projects: state.projects, roster, call: call4, reload }) + ]; +} // src/client/context-menu.js var import_react41 = require("react"); @@ -19796,7 +19947,7 @@ function projectBoardView({ project, rows, canManage, tasks, decisions, meetings groups, count: list.length, empty: list.length === 0, - emptyText: "Nothing under this Project yet.", + emptyText: "No cards yet.", record: projectBoardRecord({ decisions, meetings }) }; } @@ -19887,7 +20038,8 @@ function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) ); } -function ProjectBoardOverlay({ actions } = {}) { +function ProjectBoardOverlay({ actions, call: request } = {}) { + const read = request ?? call; const [state, setState] = (0, import_react46.useState)(null); const [hash, setHash] = (0, import_react46.useState)(() => typeof window === "undefined" ? "" : window.location.hash); const close = (0, import_react46.useCallback)(() => { @@ -19911,20 +20063,20 @@ function ProjectBoardOverlay({ actions } = {}) { let live = true; void (async () => { try { - const listing = await call("projects"); + const listing = await read("projects"); const projects = listing?.projects ?? []; const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); if (project === void 0) { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); return; } - const [read, board, taskList] = await Promise.all([ - call("project-read", { id: project.id }), - call("project-board", { id: project.id }), - call("tasks", {}) + const [record, board, taskList] = await Promise.all([ + read("project-read", { id: project.id }), + read("project-board", { id: project.id }), + read("tasks", {}) ]); const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); + if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); } @@ -19951,7 +20103,15 @@ function ProjectBoardOverlay({ actions } = {}) { (0, import_react46.createElement)( "div", { className: "bt-project-body" }, - state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { + state.project === null ? (0, import_react46.createElement)( + "div", + { className: "bt-project-empty" }, + (0, import_react46.createElement)("p", null, t("No Project yet — create one.")), + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { + close(); + actions?.openSettings?.("projects"); + } }, t("Open Projects in Settings")) + ) : (0, import_react46.createElement)(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index fdb570376..560518e72 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1319,6 +1319,20 @@ const ZH = { 'not-started': '未开始', 'in-progress': '进行中', 'done': '已完成', + 'Projects': '项目', + 'New Project': '新建项目', + 'Project name': '项目名称', + 'e.g. Website': '例如:官网', + "Optional. Where this Project's work happens.": '可选。这个项目的工作目录。', + 'Projects · {count}': '项目 · {count}', + 'No Project yet — create one.': '还没有项目——新建一个。', + 'Bind a Bot or group': '绑定 Bot 或群聊', + 'Bind to {name}': '绑定到 {name}', + 'Open board': '打开看板', + 'Unbind': '解除绑定', + 'Bindings': '绑定关系', + 'No cards yet.': '还没有卡片。', + 'Open Projects in Settings': '在设置中打开项目', } // Host errors stay English, since Bots read them too; they are translated where the diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 5b33db460..e371d15f9 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -159,7 +159,7 @@ export function projectBoardView({ project, rows, canManage, tasks, decisions, m groups, count: list.length, empty: list.length === 0, - emptyText: 'Nothing under this Project yet.', + emptyText: 'No cards yet.', record: projectBoardRecord({ decisions, meetings }), } } @@ -236,7 +236,9 @@ export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, m // The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. // Reading is a POST through the same host channel every other view uses. -export function ProjectBoardOverlay({ actions } = {}) { +export function ProjectBoardOverlay({ actions, call: request } = {}) { + // `call` is a seam for the tests only; in the app it is the channel every other view uses. + const read = request ?? call const [state, setState] = useState(null) // The hash is the whole entry point, so it has to be read again every time it changes: an // entry that writes `#project=` while this mounts opened nothing when the read was one-shot. @@ -259,19 +261,19 @@ export function ProjectBoardOverlay({ actions } = {}) { let live = true void (async () => { try { - const listing = await call('projects') + const listing = await read('projects') const projects = listing?.projects ?? [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); return } // Four reads once, none polling: the Project, its record, its rows, and the task texts the // rows are joined against. Nothing here asks a Bot by Bot. - const [read, board, taskList] = await Promise.all([ - call('project-read', { id: project.id }), - call('project-board', { id: project.id }), - call('tasks', {}), + const [record, board, taskList] = await Promise.all([ + read('project-read', { id: project.id }), + read('project-board', { id: project.id }), + read('tasks', {}), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) + if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } @@ -288,7 +290,9 @@ export function ProjectBoardOverlay({ actions } = {}) { h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-project-close', autoFocus: true, onClick: close }, t('Close'))), h('div', { className: 'bt-project-body' }, state.project === null - ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) + ? h('div', { className: 'bt-project-empty' }, + h('p', null, t('No Project yet — create one.')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { close(); actions?.openSettings?.('projects') } }, t('Open Projects in Settings'))) // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads // that hash and lands on the Bot or group the task belongs to. : h(ProjectBoardPane, { diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 828f84a40..ef313af17 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -4,7 +4,7 @@ import { isMainOf } from './sources.js' import { allThemes } from './themes.js' import { WorkflowIcon } from './icons.js' import { WorkflowsPage } from './workflows.js' -import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' +import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, FolderIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' @@ -19,6 +19,7 @@ import { ConnectorsPage } from './connectors.js' import { CapabilitiesPage } from './capability-settings.js' import { SchedulesPage } from './schedules.js' import { FeedbackIcon, FeedbackPage } from './feedback.js' +import { FolderMenu } from './folder-menu.js' import { AboutPage } from './about.js' import { t } from './i18n.js' @@ -26,8 +27,8 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) // Settings dialog: a 198px page list beside the page. Bots and group chats each list // their members, and one opens on its own settings form. Usage widens the dialog. -const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] -const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } +const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] +const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } function SetSection({ title, children }) { return h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children)) @@ -229,6 +230,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = shown === 'bots' && !bot ? h(BotsPage, { roster, actions, run }) : null, room ? h('div', { className: 'bt-settings-form' }, h(GroupSettingsForm, { key: room.id, room, roster, actions, run })) : null, shown === 'groups' && !room ? h(GroupsPage, { roster, actions, run }) : null, + shown === 'projects' ? h(ProjectsPage, { roster, actions, run, call }) : null, shown === 'connectors' ? h(ConnectorsPage, { roster, actions }) : null, shown === 'workflows' ? h(WorkflowsPage, { call, roster }) : null, pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null, @@ -242,3 +244,105 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = h(UsagePage, { roster, actions, focusId: usageFocus })) : null), h('button', { type: 'button', className: 'bt-settings-close', 'aria-label': t('Close settings'), onClick: close }, h(CloseIcon)))) } + +// Settings → Projects (#1766): the write half of the Project record. A fresh install has none, so +// the empty state here is the entry point — create one, then its row opens the board through the +// hash the board already reads. The working folder comes from the folder picker every other +// "where does this work happen" control uses; a typed path would be a second way to say it. +const openProjectBoard = (id) => { + if (typeof window !== 'undefined') window.location.hash = `#project=${id}` +} + +const projectMembers = (roster) => [ + ...(roster?.bots ?? []).filter(bot => !bot.remote).map(bot => ({ kind: 'bot', id: bot.id, name: bot.name, projectId: bot.projectId ?? null })), + ...(roster?.rooms ?? []).map(room => ({ kind: 'group', id: room.id, name: room.name, projectId: room.projectId ?? null })), +] + +export function ProjectsList({ projects, roster, call, reload }) { + const members = projectMembers(roster) + // The binding is the member's own field (`bot.projectId`, `room.projectId`); the record is only + // asked again so the row that shows it is read from the host, not guessed here. + const bind = async (choice, project) => { + const [kind, id] = String(choice ?? '').split(':') + if (!kind || !id) return + await call('project-bind', { kind, id, projectId: project.id }) + await reload?.() + } + const unbind = async (member) => { + await call('project-bind', { kind: member.kind, id: member.id, projectId: null }) + await reload?.() + } + const bound = members.filter(member => member.projectId !== null && member.projectId !== undefined) + return [ + h(SetSection, { key: 'projects', title: t('Projects · {count}', { count: projects.length }) }, + projects.length === 0 + ? h('div', { className: 'bt-set-row' }, h('div', { className: 'bt-set-copy' }, h('span', null, t('No Project yet — create one.')))) + : projects.map(project => h(SetRow, { + key: project.id, label: project.name, + hint: project.workDir === null || project.workDir === undefined ? undefined : project.workDir, + }, + h('div', { className: 'bt-project-row-controls' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openProjectBoard(project.id) }, t('Open board')), + h('select', { + className: 'bt-select', 'aria-label': t('Bind to {name}', { name: project.name }), value: '', + onChange: event => { void bind(event.target.value, project) }, + }, + h('option', { value: '' }, t('Bind a Bot or group')), + members.map(member => h('option', { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` }, + `${member.kind === 'group' ? t('Group') : t('Bot')} · ${member.name}`))))))), + bound.length === 0 ? null : h(SetSection, { key: 'bindings', title: t('Bindings') }, + bound.map(member => h(SetRow, { + key: `${member.kind}:${member.id}`, label: member.name, + hint: member.kind === 'group' ? t('Group') : t('Bot'), + }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))), + ] +} + +export function ProjectsPage({ roster, actions, run, call }) { + const [state, setState] = useState({ projects: [], error: null }) + const [name, setName] = useState('') + const [workDir, setWorkDir] = useState(null) + const reload = useCallback(async () => { + const listing = await call('projects') + setState({ projects: listing?.projects ?? [], error: null }) + }, [call]) + useEffect(() => { + void reload().catch(error => setState({ projects: [], error: error?.message ?? String(error) })) + }, [reload]) + // Creating re-reads the list rather than appending the answer: the host decides what a Project + // is, and the row then shows the record it stored. + const create = async () => { + const trimmed = name.trim() + if (trimmed === '') return + const body = { name: trimmed } + if (workDir !== null && workDir !== '') body.workDir = workDir + try { + await call('project-create', body) + setName('') + setWorkDir(null) + await reload() + } catch (error) { + setState({ projects: state.projects, error: error?.message ?? String(error) }) + } + } + return [ + state.error === null ? null : h('div', { key: 'error', className: 'bt-error', role: 'alert' }, state.error), + h(SetSection, { key: 'new', title: t('New Project') }, + h(SetRow, { label: t('Name') }, + h('input', { + className: 'bt-input', 'aria-label': t('Project name'), value: name, placeholder: t('e.g. Website'), + onChange: event => setName(event.target.value), + })), + h(SetRow, { label: t('Working folder'), hint: t("Optional. Where this Project's work happens.") }, + h(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir })), + h('div', { className: 'bt-actions' }, + h('button', { + type: 'button', className: 'bt-primary bt-with-icon', disabled: name.trim() === '', + onClick: () => { void create() }, + }, h(PlusIcon), t('New Project')))), + // The list is rendered here rather than as a child element: it holds no state of its own, + // and the page keeps one reload path for both halves. + ...ProjectsList({ projects: state.projects, roster, call, reload }), + ] +} diff --git a/core/src/host/store.js b/core/src/host/store.js index 9b1eacd8f..517ef5282 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -372,6 +372,9 @@ export function install(rt) { // member resolves `null`, so an install with zero Projects behaves exactly as before. const PROJECT_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ const PROJECT_STATUSES = new Set(['active', 'archived']) + // A Project's working folder is the absolute path the folder picker hands back (#1766), never a + // relative one: a record that says 'src' would mean a different folder on every machine. + const PROJECT_WORKDIR = /^(?:\/|[A-Za-z]:[\\/]|~[\\/]|\\\\)[^\u0000-\u001f]*$/ const projectText = (value, limit) => (typeof value === 'string' ? value.trim().slice(0, limit) : '') const projectRefusal = (message, code = 'bot/invalid') => { const error = new Error(message) @@ -406,6 +409,8 @@ export function install(rt) { if (name === '') throw projectRefusal('A Project needs a name') const repository = projectText(body.repository, 200) if (repository !== '' && !PROJECT_REPOSITORY.test(repository)) throw projectRefusal('A Project repository is owner/name') + const workDir = projectText(body.workDir, 400) + if (workDir !== '' && !PROJECT_WORKDIR.test(workDir)) throw projectRefusal('A Project working folder is an absolute path') const status = projectText(body.status, 40) || 'active' if (!PROJECT_STATUSES.has(status)) throw projectRefusal(`Unknown Project status ${status}`) const now = Date.now() @@ -415,6 +420,7 @@ export function install(rt) { name, repository: repository === '' ? null : repository, defaultBranch: projectText(body.defaultBranch, 120) || 'main', + workDir: workDir === '' ? null : workDir, ownerUserId: projectText(body.ownerUserId, 120) || null, // Further owners of the same Project (#1733): each has their own memory and their own digest, // built from the same board read. An empty list is a single-owner Project. diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index c05847f36..3ee6fd7b8 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -12,23 +12,40 @@ const bundle = await build({ bundle: true, format: 'cjs', platform: 'node', write: false, external: ['react', 'react-dom'], }) +// A hook runtime: the overlay is a plain function, so a slot array that survives a re-render is +// enough to mount it, let its one read land, and read the tree it draws. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {} } const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, console, + module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, console, window: win, require: (name) => { - const platform = { - react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), - useCallback: fn => fn, - useEffect: () => {}, - useState: initial => [initial, () => {}], - }, - } - if (!(name in platform)) throw new Error(`no platform module ${name}`) - return platform[name] + if (name === 'react') return react + throw new Error(`no platform module ${name}`) }, }) -const { ProjectBoardPane, projectBoardView } = module.exports +const { ProjectBoardOverlay, ProjectBoardPane, projectBoardView } = module.exports +const render = (component, props) => { index = 0; return component(props) } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + await new Promise(resolve => setTimeout(resolve, 0)) +} const rows = [ { id: 'task-b1', source: 'bot', column: 'in-progress', assignee: 'Dev4', updatedAt: 3 }, @@ -103,7 +120,24 @@ test('the pane draws the grouped rows and only the owner sees the management con test('an empty Project renders the empty text and no row', () => { const nodes = all(ProjectBoardPane({ project: { id: 'p', name: 'P' }, rows: [], canManage: false })) assert.equal(nodes.filter(node => node.props?.['data-task-id'] !== undefined).length, 0) - assert.match(nodes.map(textOf).join(' '), /Nothing under this Project yet/) + assert.match(nodes.map(textOf).join(' '), /No cards yet/) +}) + +test('a Project-less entry offers the page that creates one (#1766)', async () => { + slots = [] + effects = [] + win.location.hash = '#project' + const opened = [] + const call = async () => ({ projects: [] }) + let tree = render(ProjectBoardOverlay, { actions: { openSettings: key => opened.push(key) }, call }) + await flush() + tree = render(ProjectBoardOverlay, { actions: { openSettings: key => opened.push(key) }, call }) + const nodes = all(tree) + assert.match(nodes.map(textOf).join(' '), /No Project yet — create one/) + const button = nodes.find(node => node.type === 'button' && /Open Projects in Settings/.test(textOf(node))) + assert.ok(button, 'the empty state names the way out') + button.props.onClick() + assert.deepEqual(opened, ['projects']) }) // #1767: the record beside the cards. The view passes through what the host read — a decision diff --git a/core/test/project-settings-page.test.js b/core/test/project-settings-page.test.js new file mode 100644 index 000000000..a9197877a --- /dev/null +++ b/core/test/project-settings-page.test.js @@ -0,0 +1,161 @@ +// Settings → Projects (#1766): a fresh install has no Project, so the page *is* the way to make +// one. The page calls `project-create` and then reads `projects` again; a binding is the member's +// own field, so binding and unbinding both go through `project-bind`; and the working folder comes +// from the folder picker every other "where does this happen" control uses, not a typed path. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { SettingsDialog, ProjectsPage, ProjectsList } from './src/client/settings.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// A small hook runtime: the pages are plain functions, so a slot array that survives a re-render is +// enough to type a name and press the button the way a person would. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SettingsDialog, ProjectsPage, ProjectsList } = module.exports + +const render = (component, props) => { index = 0; return component(props) } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + await new Promise(resolve => setTimeout(resolve, 0)) +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string') return node + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} +const roster = { + bots: [{ id: 'a', name: 'Chief' }, { id: 'b', name: 'Writer' }], + rooms: [{ id: 'r1', name: 'Ops', projectId: 'project-1' }], + byId: {}, roomsById: {}, prefs: {}, mainBotIds: [], connectors: [], +} +const actions = { openSettings() {}, closeSettings() {} } + +test('Settings lists a Projects page beside Bots and group chats, and draws it', () => { + const tree = SettingsDialog({ page: 'projects', id: null, roster, actions, useUpdate: () => ({}) }) + const nodes = all(tree) + const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) + assert.deepEqual(items.slice(0, 4), ['General', 'Bots', 'Group chats', 'Projects']) + assert.equal(text(nodes.find(node => node.type === 'h2')), 'Projects') + const page = nodes.find(node => typeof node.type === 'function' && typeof node.props?.call === 'function' && node.props?.roster === roster) + assert.ok(page, 'the Projects page is the one rendered for the page key') +}) + +test('an install with no Project says so, and creating one re-reads the list', async () => { + const calls = [] + const call = async (endpoint, body) => { + // The body is built inside the bundle, so it is copied across the realm before comparing. + calls.push(body === undefined ? [endpoint] : [endpoint, JSON.parse(JSON.stringify(body))]) + if (endpoint === 'projects') return { projects: [] } + if (endpoint === 'project-create') return { project: { id: 'project-1', name: body.name, workDir: body.workDir ?? null } } + return {} + } + slots = [] + effects = [] + let page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + await flush() + page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + let nodes = all(page) + assert.match(nodes.map(text).join(' '), /No Project yet — create one/) + + // The folder is chosen, not typed: the one text input is the name. + const inputs = nodes.filter(node => node.type === 'input') + assert.equal(inputs.length, 1) + assert.equal(inputs[0].props['aria-label'], 'Project name') + const folderRow = nodes.find(node => node.props?.label === 'Working folder') + assert.ok(folderRow, 'the create form has a working folder row') + const picker = all(folderRow).find(node => typeof node.type === 'function' && typeof node.props?.onPick === 'function') + assert.ok(picker, 'the working folder comes from the folder picker') + + inputs[0].props.onChange({ target: { value: 'Website' } }) + page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + nodes = all(page) + picker.props.onPick('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/work/site') + page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + nodes = all(page) + const create = nodes.find(node => node.type === 'button' && /New Project/.test(text(node))) + assert.ok(create) + create.props.onClick() + await flush() + + assert.deepEqual(calls, [ + ['projects'], + ['project-create', { name: 'Website', workDir: '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/work/site' }], + ['projects'], + ], 'a new Project is created and the list is read again') +}) + +test('a row opens its board, a Bot binds with its Project id, and unbinding sends null', async () => { + const calls = [] + const call = async (endpoint, body) => { calls.push([endpoint, JSON.parse(JSON.stringify(body))]) } + const reload = async () => { calls.push(['projects']) } + const project = { id: 'project-1', name: 'Site', workDir: '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/work/site' } + const nodes = all(ProjectsList({ projects: [project], roster, call, reload })) + assert.ok(nodes.some(node => node.props?.label === 'Site'), 'the Project is listed by name') + + const open = nodes.find(node => node.type === 'button' && text(node) === 'Open board') + assert.ok(open, 'a row opens the board') + win.location.hash = '' + open.props.onClick() + assert.equal(win.location.hash, '#project=project-1', 'the row opens the hash the board already reads') + + const select = nodes.find(node => node.type === 'select') + assert.ok(select, 'a Bot or group can be bound from the row') + select.props.onChange({ target: { value: 'bot:a' } }) + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(calls[0], ['project-bind', { kind: 'bot', id: 'a', projectId: 'project-1' }]) + assert.deepEqual(calls[1], ['projects'], 'the binding is read back, not assumed') + + const unbind = nodes.find(node => node.type === 'button' && text(node) === 'Unbind') + assert.ok(unbind, 'a bound group is listed with an unbind control') + unbind.props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(calls[2], ['project-bind', { kind: 'group', id: 'r1', projectId: null }]) +}) diff --git a/core/test/projects.test.js b/core/test/projects.test.js index fd3457c9f..2a78a8ccc 100644 --- a/core/test/projects.test.js +++ b/core/test/projects.test.js @@ -269,3 +269,24 @@ test('a signed-in member may read the Project board even though it may not manag assert.deepEqual(value(board).tasks, []) } finally { await stop() } }) + +// #1766: the Settings page needs somewhere to say where a Project works, so the record carries an +// optional folder — the absolute path the folder picker hands back, never a relative one. +test('a Project carries an optional working folder and refuses a relative one', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Site', workDir: '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/work/site' })) + assert.equal(project.workDir, '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/work/site') + assert.equal(created(await call('project-read', { id: project.id })).workDir, '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/work/site') + assert.equal(value(await call('projects', {})).projects[0].workDir, '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/work/site', 'the list carries it too') + + assert.equal(created(await call('project-create', { name: 'Bare' })).workDir, null) + // An empty value is the field left alone, not a relative folder. + assert.equal(created(await call('project-create', { name: 'Blank', workDir: '' })).workDir, null); + for (const workDir of ['site', '../site', 'not-absolute/site']) { + const refused = await call('project-create', { name: 'Bad', workDir }) + assert.equal(refused.ok, false, `${JSON.stringify(workDir)} is not a folder`) + assert.match(refused.error.message, /absolute path/) + } + } finally { await stop() } +}) diff --git a/core/test/settings-page-slot.test.js b/core/test/settings-page-slot.test.js index 36c617700..73fff6f8b 100644 --- a/core/test/settings-page-slot.test.js +++ b/core/test/settings-page-slot.test.js @@ -39,7 +39,7 @@ test('a flybot.settings.page entry is listed after Connectors and drawn with the const tree = SettingsDialog({ page: 'plugin:social', id: null, roster, actions, useUpdate: () => ({}) }) const nodes = all(tree) const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) - assert.deepEqual(items.slice(3, 6), ['Connectors', 'Bot Social', 'Capabilities']) + assert.deepEqual(items.slice(4, 7), ['Connectors', 'Bot Social', 'Capabilities']) assert.equal(text(nodes.find(node => node.type === 'h2')), 'Bot Social') const slot = nodes.find(node => node.props?.entry?.id === 'social') assert.ok(slot) @@ -56,6 +56,6 @@ test('a page whose plugin is gone falls back to General, and without plugins the const nodes = all(tree) assert.equal(text(nodes.find(node => node.type === 'h2')), 'General') const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) - assert.deepEqual(items.slice(0, 6), ['General', 'Bots', 'Group chats', 'Connectors', 'Capabilities', 'Scheduled tasks']) + assert.deepEqual(items.slice(0, 7), ['General', 'Bots', 'Group chats', 'Projects', 'Connectors', 'Capabilities', 'Scheduled tasks']) assert.equal(nodes.some(node => node.props?.entry !== undefined), false) }) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1a8f0c340..e479fe494 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1413,7 +1413,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Working folder': '工作文件夹', 'Working folder: {folder}': '工作文件夹:{folder}', Folder: '文件夹', -@@ -838,6 +1307,18 @@ +@@ -838,6 +1307,32 @@ 'Up one level': '上一级', 'No folders inside.': '里面没有文件夹。', 'Some folders are not shown.': '还有一些文件夹没有显示。', @@ -1429,10 +1429,24 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'not-started': '未开始', + 'in-progress': '进行中', + 'done': '已完成', ++ 'Projects': '项目', ++ 'New Project': '新建项目', ++ 'Project name': '项目名称', ++ 'e.g. Website': '例如:官网', ++ "Optional. Where this Project's work happens.": '可选。这个项目的工作目录。', ++ 'Projects · {count}': '项目 · {count}', ++ 'No Project yet — create one.': '还没有项目——新建一个。', ++ 'Bind a Bot or group': '绑定 Bot 或群聊', ++ 'Bind to {name}': '绑定到 {name}', ++ 'Open board': '打开看板', ++ 'Unbind': '解除绑定', ++ 'Bindings': '绑定关系', ++ 'No cards yet.': '还没有卡片。', ++ 'Open Projects in Settings': '在设置中打开项目', } // Host errors stay English, since Bots read them too; they are translated where the -@@ -853,8 +1334,11 @@ +@@ -853,8 +1348,11 @@ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, '只有你、主 Bot 或「$1」的管理员可以修改'], [/^A key must have at least (\d+) characters$/, '密钥至少 $1 个字符'], [/^A key can have at most (\d+) characters$/, '密钥最多 $1 个字符'], @@ -1444,7 +1458,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js [/^HTTP (\d+)$/, '请求失败(HTTP $1)'], [/^GitHub answered (\d+)$/, 'GitHub 返回了 $1'], [/^Keep the name under (\d+) characters$/, '名字不能超过 $1 个字'], -@@ -883,6 +1367,20 @@ +@@ -883,6 +1381,20 @@ 'Unknown Bot': '找不到这个 Bot', 'Unknown conversation': '找不到这个对话', 'A group chat needs at least two Bots': '群聊至少要两个 Bot', @@ -1465,7 +1479,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'The admin must be a member of the group': '管理员必须是群成员', 'Unknown group chat': '找不到这个群聊', 'Hand the admin role to another member instead of leaving the group without one': '请把管理员交给其他成员,不要让群聊没有管理员', -@@ -905,6 +1403,8 @@ +@@ -905,6 +1417,8 @@ 'Write the entry to save.': '请写下要保存的内容。', 'This DeepSeek Harness has no folder chooser': '这个 DeepSeek Harness 没有文件夹选择窗口', 'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹', @@ -1474,6 +1488,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js } Object.assign(ZH, HOST_ZH) + diff --git a/src/client/member-status.js b/src/client/member-status.js --- a/src/client/member-status.js +++ b/src/client/member-status.js diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 9e5860576..523e18e13 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -862,7 +862,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,378 @@ +@@ -161,12 +161,384 @@ migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -927,6 +927,9 @@ diff --git a/src/host/store.js b/src/host/store.js + // member resolves `null`, so an install with zero Projects behaves exactly as before. + const PROJECT_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/ + const PROJECT_STATUSES = new Set(['active', 'archived']) ++ // A Project's working folder is the absolute path the folder picker hands back (#1766), never a ++ // relative one: a record that says 'src' would mean a different folder on every machine. ++ const PROJECT_WORKDIR = /^(?:\/|[A-Za-z]:[\\/]|~[\\/]|\\\\)[^\u0000-\u001f]*$/ + const projectText = (value, limit) => (typeof value === 'string' ? value.trim().slice(0, limit) : '') + const projectRefusal = (message, code = 'bot/invalid') => { + const error = new Error(message) @@ -961,6 +964,8 @@ diff --git a/src/host/store.js b/src/host/store.js + if (name === '') throw projectRefusal('A Project needs a name') + const repository = projectText(body.repository, 200) + if (repository !== '' && !PROJECT_REPOSITORY.test(repository)) throw projectRefusal('A Project repository is owner/name') ++ const workDir = projectText(body.workDir, 400) ++ if (workDir !== '' && !PROJECT_WORKDIR.test(workDir)) throw projectRefusal('A Project working folder is an absolute path') + const status = projectText(body.status, 40) || 'active' + if (!PROJECT_STATUSES.has(status)) throw projectRefusal(`Unknown Project status ${status}`) + const now = Date.now() @@ -970,6 +975,7 @@ diff --git a/src/host/store.js b/src/host/store.js + name, + repository: repository === '' ? null : repository, + defaultBranch: projectText(body.defaultBranch, 120) || 'main', ++ workDir: workDir === '' ? null : workDir, + ownerUserId: projectText(body.ownerUserId, 120) || null, + // Further owners of the same Project (#1733): each has their own memory and their own digest, + // built from the same board read. An empty list is a single-owner Project. @@ -1415,6 +1421,7 @@ diff --git a/src/host/store.js b/src/host/store.js + projectMemory, projectMemoryOf, projectMemoryFor, appendProjectMemory, setProjectDigest, deleteProject, }) } + diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 0b7c96f2f..0440ab979 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -832,7 +832,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -887,7 +1353,19 @@ +@@ -887,7 +1353,33 @@ "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", @@ -849,11 +849,25 @@ diff --git a/client.js b/client.js + // The Project board names its columns with the same keys the task record uses. + "not-started": "未开始", + "in-progress": "进行中", -+ "done": "已完成" ++ "done": "已完成", ++ "Projects": "项目", ++ "New Project": "新建项目", ++ "Project name": "项目名称", ++ "e.g. Website": "例如:官网", ++ "Optional. Where this Project's work happens.": "可选。这个项目的工作目录。", ++ "Projects · {count}": "项目 · {count}", ++ "No Project yet — create one.": "还没有项目——新建一个。", ++ "Bind a Bot or group": "绑定 Bot 或群聊", ++ "Bind to {name}": "绑定到 {name}", ++ "Open board": "打开看板", ++ "Unbind": "解除绑定", ++ "Bindings": "绑定关系", ++ "No cards yet.": "还没有卡片。", ++ "Open Projects in Settings": "在设置中打开项目" }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], -@@ -900,8 +1378,11 @@ +@@ -900,8 +1392,11 @@ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, "只有你、主 Bot 或「$1」的管理员可以修改"], [/^A key must have at least (\d+) characters$/, "密钥至少 $1 个字符"], [/^A key can have at most (\d+) characters$/, "密钥最多 $1 个字符"], @@ -865,7 +879,7 @@ diff --git a/client.js b/client.js [/^HTTP (\d+)$/, "请求失败(HTTP $1)"], [/^GitHub answered (\d+)$/, "GitHub 返回了 $1"], [/^Keep the name under (\d+) characters$/, "名字不能超过 $1 个字"], -@@ -930,6 +1411,20 @@ +@@ -930,6 +1425,20 @@ "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -886,7 +900,7 @@ diff --git a/client.js b/client.js "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -951,10 +1446,12 @@ +@@ -951,10 +1460,12 @@ "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -901,7 +915,7 @@ diff --git a/client.js b/client.js var service = null; var bound = null; function installLocale(locale) { -@@ -1005,15 +1502,23 @@ +@@ -1005,15 +1516,23 @@ } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; @@ -928,7 +942,7 @@ diff --git a/client.js b/client.js if (entries.length > MAX) entries.splice(0, entries.length - MAX); } var quietly = (where) => (error) => noteClientError(where, error); -@@ -1042,16 +1547,21 @@ +@@ -1042,16 +1561,21 @@ }; function failureLabel(code, message) { if (FAILURES[code]) return t(FAILURES[code]); @@ -956,7 +970,7 @@ diff --git a/client.js b/client.js const full2 = build(diagnostics); if (full2.length <= URL_LIMIT) return { url: full2, inline: true }; const facts = build(`${diagnostics.split("\n\n")[0]} -@@ -1066,10 +1576,16 @@ +@@ -1066,10 +1590,16 @@ var call = async (endpoint, payload, signal) => { let response; try { @@ -974,7 +988,7 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1087,7 +1603,7 @@ +@@ -1087,7 +1617,7 @@ if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); @@ -983,7 +997,7 @@ diff --git a/client.js b/client.js if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); markNoted(error); -@@ -1097,6 +1613,388 @@ +@@ -1097,6 +1627,388 @@ return result.value; }; @@ -1372,7 +1386,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1160,9 +2058,9 @@ +@@ -1160,9 +2072,9 @@ var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); @@ -1384,7 +1398,7 @@ diff --git a/client.js b/client.js return value >>> 0; } function defaultColor(id) { -@@ -1177,19 +2075,96 @@ +@@ -1177,19 +2089,96 @@ value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1484,7 +1498,7 @@ diff --git a/client.js b/client.js ``, `` ].join(""); -@@ -1206,8 +2181,8 @@ +@@ -1206,8 +2195,8 @@ return ""; } } @@ -1495,7 +1509,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,192 +2217,46 @@ +@@ -1242,192 +2231,46 @@ } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1549,8 +1563,17 @@ diff --git a/client.js b/client.js - else delete own[callId]; - } - return { ...state, [sessionId]: own }; --} -- ++// src/client/cluster-order.js ++function clusterOrder(people2) { ++ const list = Array.isArray(people2) ? people2 : []; ++ const remote = (bot) => Boolean(bot?.remote); ++ if (!list.some(remote)) return list; ++ const locals = list.filter((bot) => !remote(bot)); ++ const remotes = list.filter(remote); ++ if (list.length <= 4) return [...remotes, ...locals]; ++ return [...locals, ...remotes]; + } + -// src/client/themes.js -var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; -var THEMES = { @@ -1653,18 +1676,9 @@ diff --git a/client.js b/client.js - else delete customThemes[id]; - registry.set((value) => value + 1); - }; -+// src/client/cluster-order.js -+function clusterOrder(people2) { -+ const list = Array.isArray(people2) ? people2 : []; -+ const remote = (bot) => Boolean(bot?.remote); -+ if (!list.some(remote)) return list; -+ const locals = list.filter((bot) => !remote(bot)); -+ const remotes = list.filter(remote); -+ if (list.length <= 4) return [...remotes, ...locals]; -+ return [...locals, ...remotes]; - } +-} -var allThemes = () => ({ ...THEMES, ...customThemes }); - +- // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; @@ -1707,7 +1721,7 @@ diff --git a/client.js b/client.js const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) -@@ -1445,24 +2274,86 @@ +@@ -1445,24 +2288,86 @@ } return spans; } @@ -1799,7 +1813,7 @@ diff --git a/client.js b/client.js function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1483,9 +2374,18 @@ +@@ -1483,9 +2388,18 @@ var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; var currentPart = (bot) => bot.sessionId ?? bot.id; @@ -1821,7 +1835,7 @@ diff --git a/client.js b/client.js try { const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); -@@ -1493,38 +2393,121 @@ +@@ -1493,38 +2407,121 @@ return []; } } @@ -1950,7 +1964,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,16 +2580,45 @@ +@@ -1597,17 +2594,46 @@ } return { count, peerIds }; } @@ -1975,7 +1989,7 @@ diff --git a/client.js b/client.js + return readableLine(text2, names); } return ""; -+} + } +function readableLine(text2, names = []) { + return dropKnownTrace(collapsedLine(text2), names); +} @@ -1996,10 +2010,11 @@ diff --git a/client.js b/client.js +var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== ""); +function speechOf(text2, names = []) { + return readableLine(text2, names).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); - } ++} var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { -@@ -1624,9 +2636,982 @@ + let input = 0; +@@ -1624,9 +2650,982 @@ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -2023,7 +2038,8 @@ diff --git a/client.js b/client.js +function awayGroups(groups, roomsById) { + return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); +} -+ + +-// src/client/motion.js +// src/client/sources.js +function createSource(initial) { + let value = initial; @@ -2099,7 +2115,7 @@ diff --git a/client.js b/client.js +} + +// src/client/mark.js -+var import_react = require("react"); + var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; @@ -2976,14 +2992,13 @@ diff --git a/client.js b/client.js + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; + }).join("\n"); +} - - // src/client/motion.js --var import_react = require("react"); ++ ++// src/client/motion.js +var import_react4 = require("react"); var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); -@@ -1659,12 +3644,12 @@ +@@ -1659,12 +3658,12 @@ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); @@ -2999,7 +3014,7 @@ diff --git a/client.js b/client.js } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -1699,10 +3684,10 @@ +@@ -1699,10 +3698,10 @@ } var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { @@ -3013,7 +3028,7 @@ diff --git a/client.js b/client.js if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +3721,7 @@ +@@ -1736,7 +3735,7 @@ springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; @@ -3022,7 +3037,7 @@ diff --git a/client.js b/client.js const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; -@@ -1750,7 +3735,7 @@ +@@ -1750,7 +3749,7 @@ } place(fresh); }); @@ -3031,7 +3046,7 @@ diff --git a/client.js b/client.js own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +3743,16 @@ +@@ -1758,16 +3757,16 @@ } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -3052,7 +3067,7 @@ diff --git a/client.js b/client.js const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +3773,8 @@ +@@ -1788,8 +3787,8 @@ }, [key]); } function useMorph(box, key) { @@ -3063,7 +3078,7 @@ diff --git a/client.js b/client.js const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +3802,8 @@ +@@ -1817,8 +3816,8 @@ }); } function useLinger(value, ms) { @@ -3074,7 +3089,7 @@ diff --git a/client.js b/client.js if (value) { setKept(value); return void 0; -@@ -1838,6 +3823,7 @@ +@@ -1838,6 +3837,7 @@ .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} @@ -3082,7 +3097,7 @@ diff --git a/client.js b/client.js .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} -@@ -1856,6 +3842,14 @@ +@@ -1856,6 +3856,14 @@ @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} @@ -3097,7 +3112,7 @@ diff --git a/client.js b/client.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -1933,15 +3927,23 @@ +@@ -1933,15 +3941,23 @@ .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -3123,7 +3138,7 @@ diff --git a/client.js b/client.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} -@@ -1950,27 +3952,32 @@ +@@ -1950,27 +3966,32 @@ .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} @@ -3160,7 +3175,7 @@ diff --git a/client.js b/client.js .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")}} -@@ -1992,6 +3999,7 @@ +@@ -1992,6 +4013,7 @@ .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} @@ -3168,7 +3183,7 @@ diff --git a/client.js b/client.js .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -@@ -2085,6 +4093,7 @@ +@@ -2085,6 +4107,7 @@ @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} @@ -3176,7 +3191,7 @@ diff --git a/client.js b/client.js .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -@@ -2098,7 +4107,9 @@ +@@ -2098,7 +4121,9 @@ .bt-option[aria-selected=true]{background:var(--bt-active)} .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3187,7 +3202,7 @@ diff --git a/client.js b/client.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} -@@ -2127,6 +4138,10 @@ +@@ -2127,6 +4152,10 @@ .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} @@ -3198,7 +3213,7 @@ diff --git a/client.js b/client.js .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} -@@ -2150,11 +4165,11 @@ +@@ -2150,11 +4179,11 @@ .bt-soft:hover{background:var(--bt-active)} .bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} @@ -3212,7 +3227,7 @@ diff --git a/client.js b/client.js .bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} .bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} .bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} -@@ -2163,6 +4178,8 @@ +@@ -2163,6 +4192,8 @@ .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} .bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -3221,7 +3236,7 @@ diff --git a/client.js b/client.js .bt-drawer-name:hover{background:var(--bt-hover)} .bt-drawer-name-input{font-size:17px;line-height:24px;text-align:center;border:.5px solid var(--bt-line-2);border-radius:6px;padding:0 6px;background:var(--bt-main);color:var(--bt-ink);outline:none;font-weight:500} .bt-drawer-role{font-size:12px;line-height:16px;color:var(--bt-ink-3);margin-top:6px;text-align:center} -@@ -2197,7 +4214,7 @@ +@@ -2197,7 +4228,7 @@ .bt-avatar-anchor{position:relative} .bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} .bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} @@ -3230,7 +3245,7 @@ diff --git a/client.js b/client.js .bt-bs .bt-avedit{transform-origin:32px 0} .bt-avedit-top{display:flex;align-items:center;justify-content:space-between} .bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} -@@ -2237,9 +4254,23 @@ +@@ -2237,9 +4268,23 @@ .bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} /* Settings dialog: a 198px page list beside the page. */ @@ -3255,7 +3270,7 @@ diff --git a/client.js b/client.js .bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} :is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} -@@ -2255,6 +4286,7 @@ +@@ -2255,6 +4300,7 @@ .bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)} .bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent} .bt-settings-shell{margin-top:auto} @@ -3263,7 +3278,7 @@ diff --git a/client.js b/client.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -2288,13 +4320,63 @@ +@@ -2288,13 +4334,63 @@ .bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)} .bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px} .bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px} @@ -3327,7 +3342,7 @@ diff --git a/client.js b/client.js /* Settings → Connectors. */ .bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -@@ -2308,8 +4390,17 @@ +@@ -2308,8 +4404,17 @@ .bt-connector-actions{flex:none;display:flex;gap:6px} .bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-connector-form-row{display:flex;align-items:center;gap:6px} @@ -3345,7 +3360,7 @@ diff --git a/client.js b/client.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ -@@ -2361,7 +4452,7 @@ +@@ -2361,7 +4466,7 @@ .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} .bt-tool:active{background:var(--bt-active)} .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} @@ -3354,7 +3369,7 @@ diff --git a/client.js b/client.js .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} .bt-react-strip button:nth-child(2){animation-delay:20ms} .bt-react-strip button:nth-child(3){animation-delay:40ms} -@@ -2380,7 +4471,7 @@ +@@ -2380,7 +4485,7 @@ .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} @@ -3363,7 +3378,7 @@ diff --git a/client.js b/client.js .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} .bt-menu button:hover{background:var(--bt-hover)} .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} -@@ -2390,7 +4481,7 @@ +@@ -2390,7 +4495,7 @@ .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} @@ -3372,7 +3387,7 @@ diff --git a/client.js b/client.js .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-palette-layer[data-leaving]{pointer-events:none} -@@ -2427,7 +4518,8 @@ +@@ -2427,7 +4532,8 @@ .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} @@ -3382,7 +3397,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 -@@ -2494,7 +4586,7 @@ +@@ -2494,7 +4600,7 @@ .bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} .bt-mem-row-copy{flex:1;text-align:left} .bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} @@ -3391,7 +3406,7 @@ diff --git a/client.js b/client.js .bt-mem-scrim{animation:bt-fade .2s ease-out} .bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} -@@ -2597,9 +4689,20 @@ +@@ -2597,9 +4703,20 @@ `; var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} @@ -3414,7 +3429,7 @@ diff --git a/client.js b/client.js .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} .bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} -@@ -2612,7 +4715,6 @@ +@@ -2612,7 +4729,6 @@ .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} @@ -3422,7 +3437,7 @@ diff --git a/client.js b/client.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 -@@ -2622,21 +4724,48 @@ +@@ -2622,21 +4738,48 @@ [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} @@ -3479,7 +3494,7 @@ diff --git a/client.js b/client.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -@@ -2646,8 +4775,19 @@ +@@ -2646,8 +4789,19 @@ [data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} [data-chat-running]>span:not([role=status]){display:none!important} [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} @@ -3499,7 +3514,7 @@ diff --git a/client.js b/client.js .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -@@ -2679,7 +4819,8 @@ +@@ -2679,7 +4833,8 @@ @keyframes bt-fade-in{from{opacity:0}to{opacity:1}} @keyframes bt-fade-out{from{opacity:1}to{opacity:0}} [data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} @@ -3509,7 +3524,7 @@ diff --git a/client.js b/client.js [data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} -@@ -2691,6 +4832,8 @@ +@@ -2691,6 +4846,8 @@ [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} [data-composer-placeholder]{font-size:0!important;line-height:0!important} @@ -3518,7 +3533,7 @@ diff --git a/client.js b/client.js [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} -@@ -2722,7 +4865,9 @@ +@@ -2722,7 +4879,9 @@ .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} body[data-bt-room] .bt-voice{display:none!important} @@ -3528,7 +3543,7 @@ diff --git a/client.js b/client.js .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} .bt-q{order:1} -@@ -2776,6 +4921,7 @@ +@@ -2776,6 +4935,7 @@ .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} .bt-urow{display:flex;justify-content:flex-end} @@ -3536,7 +3551,7 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2784,10 +4930,10 @@ +@@ -2784,10 +4944,10 @@ .bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} .bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} `; @@ -3549,7 +3564,7 @@ diff --git a/client.js b/client.js document.head.appendChild(style); return style; } -@@ -2807,9 +4953,9 @@ +@@ -2807,9 +4967,9 @@ } return names; } @@ -3561,7 +3576,7 @@ diff --git a/client.js b/client.js if (!compiled.has(fragment)) { const exact = []; for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -@@ -2818,7 +4964,7 @@ +@@ -2818,7 +4978,7 @@ return compiled.get(fragment); }); } @@ -3570,7 +3585,7 @@ diff --git a/client.js b/client.js const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); const render = () => { -@@ -2832,7 +4978,7 @@ +@@ -2832,7 +4992,7 @@ } for (const name of list) names.add(name); } @@ -3579,7 +3594,7 @@ diff --git a/client.js b/client.js if (style.textContent !== next) style.textContent = next; }; render(); -@@ -2855,226 +5001,204 @@ +@@ -2855,226 +5015,204 @@ }; } @@ -3619,12 +3634,7 @@ diff --git a/client.js b/client.js - document.documentElement.removeEventListener("pointerleave", onLeave); - }; - }, [enabled]); -+// src/shared/face-title.js -+var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -+function isGenericFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return !n || n === "Face-to-face room"; - } +-} -var FIDGETS = ["hop", "sway", "stretch", "look"]; -function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { - const look = given ?? lookOf(bot); @@ -3654,58 +3664,24 @@ diff --git a/client.js b/client.js - if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); - const box = shapeOf(look.shape).size; - return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -+function formatFaceWhen(value) { -+ const date = value instanceof Date ? value : new Date(value); -+ if (!Number.isFinite(date.getTime())) return ""; -+ const hh = String(date.getHours()).padStart(2, "0"); -+ const mm = String(date.getMinutes()).padStart(2, "0"); -+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; - } +-} -function StarIcon({ size = 8 }) { - return (0, import_react2.createElement)( - "svg", - { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) - ); -+function isAutoFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); - } +-} -function AdminStar() { - return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+function ownersFromMembers(members) { -+ const rows = []; -+ const seen = /* @__PURE__ */ new Set(); -+ for (const member of members ?? []) { -+ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); -+ rows.push({ owner, sort: handle || key }); -+ } -+ rows.sort((a, b) => a.sort.localeCompare(b.sort)); -+ return rows.slice(0, 3).map((row) => row.owner); - } +-} -function MainStar() { - return (0, import_react2.createElement)( - "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+function uniqueOwners(owners) { -+ const seen = /* @__PURE__ */ new Set(); -+ const out = []; -+ for (const raw of owners ?? []) { -+ const owner = String(raw ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ out.push(owner); -+ } -+ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); -+ return out.slice(0, 3); - } +-} -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -3722,8 +3698,22 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; -+function joinCodeOf(code) { -+ return String(code ?? "").replace(/\D/g, "").slice(0, 6); ++// src/shared/face-title.js ++var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; ++function isGenericFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return !n || n === "Face-to-face room"; ++} ++function formatFaceWhen(value) { ++ const date = value instanceof Date ? value : new Date(value); ++ if (!Number.isFinite(date.getTime())) return ""; ++ const hh = String(date.getHours()).padStart(2, "0"); ++ const mm = String(date.getMinutes()).padStart(2, "0"); ++ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; ++} ++function isAutoFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); } -function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); @@ -3738,13 +3728,7 @@ diff --git a/client.js b/client.js - badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, - badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null - ); -+var FACE_JOIN_WINDOW_MS = 10 * 6e4; -+function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { -+ if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; -+ const deadline = Date.parse(expiresAt); -+ if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); -+ return startedAt || void 0; - } +-} -function clusterSeats(count, frame) { - if (count === 1) return [[0, 0, frame]]; - if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; @@ -3752,6 +3736,76 @@ diff --git a/client.js b/client.js - const size = frame * 5 / 9; - const rest = frame - size; - return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; ++function ownersFromMembers(members) { ++ const rows = []; ++ const seen = /* @__PURE__ */ new Set(); ++ for (const member of members ?? []) { ++ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); ++ rows.push({ owner, sort: handle || key }); + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ rows.sort((a, b) => a.sort.localeCompare(b.sort)); ++ return rows.slice(0, 3).map((row) => row.owner); + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +- } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); ++function uniqueOwners(owners) { ++ const seen = /* @__PURE__ */ new Set(); ++ const out = []; ++ for (const raw of owners ?? []) { ++ const owner = String(raw ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ out.push(owner); ++ } ++ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); ++ return out.slice(0, 3); ++} ++function joinCodeOf(code) { ++ return String(code ?? "").replace(/\D/g, "").slice(0, 6); ++} ++var FACE_JOIN_WINDOW_MS = 10 * 6e4; ++function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { ++ if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; ++ const deadline = Date.parse(expiresAt); ++ if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); ++ return startedAt || void 0; ++} +function formatFaceRoomTitle({ name, owners, members, code, at } = {}) { + const named = String(name ?? "").trim(); + if (named && !isAutoFaceTitle(named)) return named; @@ -3784,26 +3838,12 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById?.[sessionId]; + if (bot?.remote) { + return friendBotTitle(bot); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + 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 || ""; - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++} +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -3833,25 +3873,7 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -3954,7 +3976,7 @@ diff --git a/client.js b/client.js ); } var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -@@ -3086,6 +5210,7 @@ +@@ -3086,6 +5224,7 @@ var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); @@ -3962,7 +3984,7 @@ diff --git a/client.js b/client.js var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); -@@ -3093,14 +5218,14 @@ +@@ -3093,14 +5232,14 @@ var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); function PinMarkIcon() { @@ -3981,7 +4003,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -3112,21 +5237,48 @@ +@@ -3112,21 +5251,48 @@ var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); @@ -4033,7 +4055,7 @@ diff --git a/client.js b/client.js if (count === 0) return void 0; const onKey = (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -@@ -3146,10 +5298,10 @@ +@@ -3146,10 +5312,10 @@ }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -4048,7 +4070,7 @@ diff --git a/client.js b/client.js const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +5319,7 @@ +@@ -3167,7 +5333,7 @@ }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -4057,7 +4079,7 @@ diff --git a/client.js b/client.js ref: ownRef, rows: 1, value: own, -@@ -3186,7 +5338,7 @@ +@@ -3186,7 +5352,7 @@ }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -4066,7 +4088,7 @@ diff --git a/client.js b/client.js "button", { key: option.label, -@@ -3196,36 +5348,36 @@ +@@ -3196,36 +5362,36 @@ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -4116,7 +4138,7 @@ diff --git a/client.js b/client.js "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +5387,24 @@ +@@ -3235,24 +5401,24 @@ submit(); } }, @@ -4151,7 +4173,7 @@ diff --git a/client.js b/client.js ) : null, foot ); -@@ -3262,29 +5414,29 @@ +@@ -3262,29 +5428,29 @@ const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -4192,7 +4214,7 @@ diff --git a/client.js b/client.js ); })) ); -@@ -3292,8 +5444,8 @@ +@@ -3292,8 +5458,8 @@ function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -4203,7 +4225,7 @@ diff --git a/client.js b/client.js const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +5457,10 @@ +@@ -3305,10 +5471,10 @@ } pending.answer({ answers: next }); }; @@ -4216,7 +4238,7 @@ diff --git a/client.js b/client.js if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +5470,10 @@ +@@ -3318,10 +5484,10 @@ setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -4229,7 +4251,7 @@ diff --git a/client.js b/client.js ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +5481,16 @@ +@@ -3329,16 +5495,16 @@ const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; const loading = useSession?.(historyLoading) === true; if (question === void 0 || question.callId && (drawn || loading)) return null; @@ -4249,7 +4271,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,37 +5498,37 @@ +@@ -3346,37 +5512,37 @@ } // src/client/secret-card.js @@ -4299,7 +4321,7 @@ diff --git a/client.js b/client.js className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3401,42 +5553,42 @@ +@@ -3401,42 +5567,42 @@ }); } function ScopeSwitch({ value, onChange, bot }) { @@ -4359,7 +4381,7 @@ diff --git a/client.js b/client.js const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +5604,73 @@ +@@ -3452,73 +5618,73 @@ if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -4459,7 +4481,7 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +5683,46 @@ +@@ -3531,44 +5697,46 @@ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); if (waiting.length === 0) return null; @@ -4522,7 +4544,7 @@ diff --git a/client.js b/client.js const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +5737,66 @@ +@@ -3583,66 +5751,66 @@ setValue(next); setReplaced(false); }; @@ -4615,7 +4637,7 @@ diff --git a/client.js b/client.js const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +5807,43 @@ +@@ -3653,43 +5821,43 @@ } }); }; @@ -4677,7 +4699,7 @@ diff --git a/client.js b/client.js ]; } var SECRET_CSS = ` -@@ -3756,6 +5910,144 @@ +@@ -3756,6 +5924,144 @@ @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; @@ -4822,7 +4844,7 @@ diff --git a/client.js b/client.js // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; -@@ -3767,7 +6059,7 @@ +@@ -3767,7 +6073,7 @@ var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -4831,7 +4853,7 @@ diff --git a/client.js b/client.js var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -@@ -3780,29 +6072,29 @@ +@@ -3780,29 +6086,29 @@ actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -4869,7 +4891,7 @@ diff --git a/client.js b/client.js key: emoji, type: "button", className: "bt-react", -@@ -3813,9 +6105,22 @@ +@@ -3813,9 +6119,22 @@ }) ); } @@ -4893,7 +4915,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3835,12 +6140,12 @@ +@@ -3835,12 +6154,12 @@ }; }, [open]); } @@ -4912,7 +4934,7 @@ diff --git a/client.js b/client.js const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3854,36 +6159,36 @@ +@@ -3854,36 +6173,36 @@ setPanel(kind); }; const copy2 = () => { @@ -4960,7 +4982,7 @@ diff --git a/client.js b/client.js "div", { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, item(t("Copy"), CopyIcon, copy2), -@@ -3891,12 +6196,40 @@ +@@ -3891,12 +6210,40 @@ ), document.body) : null ); } @@ -5005,7 +5027,7 @@ diff --git a/client.js b/client.js ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,19 +6237,33 @@ +@@ -3904,19 +6251,33 @@ const location2 = node.location; const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; const seq = node.data.finalNode?.seq; @@ -5041,7 +5063,7 @@ diff --git a/client.js b/client.js const running = data.status === "running"; useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); if (hidden) return null; -@@ -3924,13 +6271,14 @@ +@@ -3924,13 +6285,14 @@ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; if (texts2.length === 0 && !reasoning && relays.length === 0) return null; const selfId = roster.byId[sessionId]?.id ?? sessionId; @@ -5059,7 +5081,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +6288,230 @@ +@@ -3940,61 +6302,230 @@ whole: true }); }), @@ -5125,9 +5147,7 @@ diff --git a/client.js b/client.js }); - return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); - } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( ++} +var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); +var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); +function isHeldLine(room2, text2, bot) { @@ -5140,7 +5160,9 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); -+} + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -5313,7 +5335,7 @@ diff --git a/client.js b/client.js ); } function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +6522,14 @@ +@@ -4005,14 +6536,14 @@ const target = other && other.id !== self?.id ? other : self; if (!target) return null; const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); @@ -5331,7 +5353,7 @@ diff --git a/client.js b/client.js } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +6538,11 @@ +@@ -4021,11 +6552,11 @@ return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -5348,7 +5370,7 @@ diff --git a/client.js b/client.js const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +6558,19 @@ +@@ -4041,19 +6572,19 @@ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -5371,7 +5393,7 @@ diff --git a/client.js b/client.js const node = top.current; if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; const observer = new IntersectionObserver((entries2) => { -@@ -4069,35 +6586,35 @@ +@@ -4069,35 +6600,35 @@ if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); } @@ -5418,7 +5440,7 @@ diff --git a/client.js b/client.js if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +6622,7 @@ +@@ -4105,7 +6636,7 @@ return null; } }), @@ -5427,7 +5449,7 @@ diff --git a/client.js b/client.js ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +6633,7 @@ +@@ -4116,7 +6647,7 @@ const deferred = source.kind === "bot" && source.deferred === true; const relayed = isRelayContext(data); const turn = node.location?.turn?.turn ?? data.turn; @@ -5436,7 +5458,7 @@ diff --git a/client.js b/client.js if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +6652,31 @@ +@@ -4135,23 +6666,31 @@ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -5475,7 +5497,7 @@ diff --git a/client.js b/client.js const run = relayed ? commRunOf( order.split(",").filter(Boolean).map(Number), Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -@@ -4165,7 +6690,7 @@ +@@ -4165,7 +6704,7 @@ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); } @@ -5484,7 +5506,7 @@ diff --git a/client.js b/client.js prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), bots, onOpen: (bot) => actions.openExchange(selfId, bot.id), -@@ -4174,24 +6699,24 @@ +@@ -4174,24 +6713,24 @@ } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -5514,7 +5536,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +6724,67 @@ +@@ -4199,11 +6738,67 @@ const own = state[sessionId]?.[turn]; return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; }); @@ -5583,7 +5605,7 @@ diff --git a/client.js b/client.js var SEPARATOR_GAP_MS = 30 * 60 * 1e3; function clockLabel(time, now = Date.now()) { const date = new Date(time); -@@ -4233,10 +6814,19 @@ +@@ -4233,10 +6828,19 @@ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; @@ -5604,7 +5626,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +6840,15 @@ +@@ -4250,15 +6854,15 @@ return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -5625,7 +5647,7 @@ diff --git a/client.js b/client.js } var PHRASES = { thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4320,19 +6910,22 @@ +@@ -4320,19 +6924,22 @@ } function workPhrase(tool) { if (!tool) return t("Thinking about it"); @@ -5653,7 +5675,7 @@ diff --git a/client.js b/client.js if (done) return void 0; let stop = false; let timer; -@@ -4341,10 +6934,10 @@ +@@ -4341,10 +6948,10 @@ const result = await actions.roomProgress(sessionId); if (stop) return; const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; @@ -5666,7 +5688,7 @@ diff --git a/client.js b/client.js } timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -@@ -4362,71 +6955,89 @@ +@@ -4362,71 +6969,89 @@ replies = []; } } @@ -5787,7 +5809,7 @@ diff --git a/client.js b/client.js }) ); } -@@ -4435,52 +7046,52 @@ +@@ -4435,52 +7060,52 @@ function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -5856,7 +5878,7 @@ diff --git a/client.js b/client.js case "create_group": case "update_group": case "delete_group": -@@ -4492,64 +7103,90 @@ +@@ -4492,64 +7117,90 @@ post_to_group: ["Posted in", "Couldn't post in the group"] }[tool]; if (!done) return null; @@ -5965,7 +5987,7 @@ diff --git a/client.js b/client.js var import_react_dom2 = require("react-dom"); var PHRASE_HOLD_MS = 800; var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -@@ -4559,9 +7196,69 @@ +@@ -4559,9 +7210,69 @@ const hours = Math.floor(minutes / 60); return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } @@ -6038,7 +6060,7 @@ diff --git a/client.js b/client.js if (!active) { setHost(null); return void 0; -@@ -4569,7 +7266,7 @@ +@@ -4569,7 +7280,7 @@ let frame = 0; const find = () => { frame = 0; @@ -6047,7 +6069,7 @@ diff --git a/client.js b/client.js setHost((prev) => prev === node ? prev : node); }; const observer = new MutationObserver(() => { -@@ -4581,13 +7278,13 @@ +@@ -4581,13 +7292,13 @@ cancelAnimationFrame(frame); observer.disconnect(); }; @@ -6064,7 +6086,7 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4638,21 +7335,31 @@ +@@ -4638,21 +7349,31 @@ const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; @@ -6103,7 +6125,7 @@ diff --git a/client.js b/client.js if (wantKey === null || shown?.key === wantKey) return void 0; const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -@@ -4663,26 +7370,31 @@ +@@ -4663,26 +7384,31 @@ const timer = setTimeout(make, wait); return () => clearTimeout(timer); }, [wantKey, shown]); @@ -6147,7 +6169,7 @@ diff --git a/client.js b/client.js setShown(null); const before = shift.current; shift.current = null; -@@ -4691,31 +7403,37 @@ +@@ -4691,31 +7417,37 @@ const delta = before - scroller.scrollTop; if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); }, [phase]); @@ -6197,7 +6219,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +7449,1697 @@ +@@ -4731,49 +7463,1697 @@ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } @@ -7908,7 +7930,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +9151,131 @@ +@@ -4785,55 +9165,131 @@ onClick: () => actions.openSession(id), onContextMenu }, @@ -8055,7 +8077,7 @@ diff --git a/client.js b/client.js "data-room-id": id, onClick: () => actions.openSession(id), onContextMenu: (event) => { -@@ -4841,20 +9283,20 @@ +@@ -4841,20 +9297,20 @@ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -8084,7 +8106,7 @@ diff --git a/client.js b/client.js if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +9317,10 @@ +@@ -4875,10 +9331,10 @@ } })(); }; @@ -8097,7 +8119,7 @@ diff --git a/client.js b/client.js ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +9333,7 @@ +@@ -4891,7 +9347,7 @@ }) ); } @@ -8106,7 +8128,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4905,8 +9347,8 @@ +@@ -4905,8 +9361,8 @@ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8117,7 +8139,7 @@ diff --git a/client.js b/client.js ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +9383,10 @@ +@@ -4941,10 +9397,10 @@ const preview = sessions[0]?.title ?? t("New session"); const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; @@ -8130,7 +8152,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4959,30 +9401,30 @@ +@@ -4959,30 +9415,30 @@ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8172,7 +8194,7 @@ diff --git a/client.js b/client.js key: session.id, session, agent, -@@ -4996,8 +9438,8 @@ +@@ -4996,8 +9452,8 @@ } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -8183,7 +8205,7 @@ diff --git a/client.js b/client.js const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +9456,44 @@ +@@ -5014,9 +9470,44 @@ tops.current = { layout, map: next }; }, [layout, key]); } @@ -8229,7 +8251,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,17 +9502,26 @@ +@@ -5025,17 +9516,26 @@ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -8260,7 +8282,7 @@ diff --git a/client.js b/client.js useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -@@ -5045,39 +9531,42 @@ +@@ -5045,39 +9545,42 @@ event.preventDefault(); actions.openPalette(event.key); }; @@ -8323,7 +8345,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -@@ -5116,14 +9605,14 @@ +@@ -5116,14 +9619,14 @@ setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -8343,7 +8365,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +9622,21 @@ +@@ -5133,21 +9636,21 @@ "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -8376,7 +8398,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +9644,17 @@ +@@ -5155,17 +9658,17 @@ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -8401,7 +8423,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5176,61 +9665,25 @@ +@@ -5176,61 +9679,25 @@ ), document.body) : null ); } @@ -8473,7 +8495,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +9712,18 @@ +@@ -5259,6 +9726,18 @@ else openHarnessSettings(); }); } @@ -8492,7 +8514,7 @@ diff --git a/client.js b/client.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')); trigger.click(); -@@ -5288,32 +9753,32 @@ +@@ -5288,32 +9767,32 @@ })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -8533,7 +8555,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -@@ -5341,30 +9806,32 @@ +@@ -5341,30 +9820,32 @@ const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -8578,7 +8600,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -5372,7 +9839,7 @@ +@@ -5372,7 +9853,7 @@ setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -8587,7 +8609,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +9849,8 @@ +@@ -5382,8 +9863,8 @@ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -8598,7 +8620,7 @@ diff --git a/client.js b/client.js if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5397,40 +9864,40 @@ +@@ -5397,40 +9878,40 @@ return void 0; } const last = lastBot.current; @@ -8649,7 +8671,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5456,7 +9923,7 @@ +@@ -5456,7 +9937,7 @@ .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} @@ -8658,7 +8680,7 @@ diff --git a/client.js b/client.js @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -@@ -5491,18 +9958,140 @@ +@@ -5491,18 +9972,140 @@ .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -8672,8 +8694,10 @@ diff --git a/client.js b/client.js +.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings svg{width:15px;height:15px} -+`; -+ + `; + +-// src/client/header.js +-var import_react9 = require("react"); +// src/client/busy-ack-logic.js +function busyAckText(hostAck) { + return typeof hostAck?.text === "string" ? hostAck.text.trim() : ""; @@ -8702,7 +8726,8 @@ diff --git a/client.js b/client.js + +// src/client/busy-ack.js +var import_react17 = require("react"); -+var import_react_dom4 = require("react-dom"); + var import_react_dom4 = require("react-dom"); +-var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); +function useRunningHost2(active) { + const [host, setHost] = (0, import_react17.useState)(null); + (0, import_react17.useEffect)(() => { @@ -8784,12 +8809,9 @@ diff --git a/client.js b/client.js +.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} +@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} +@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} - `; - - // src/client/header.js --var import_react9 = require("react"); --var import_react_dom4 = require("react-dom"); --var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); ++`; ++ ++// src/client/header.js +var import_react18 = require("react"); +var import_react_dom5 = require("react-dom"); +var GoArrow = () => (0, import_react18.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react18.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); @@ -8804,7 +8826,7 @@ diff --git a/client.js b/client.js const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,19 +10101,19 @@ +@@ -5512,19 +10115,19 @@ return shown; } function MemoryNotice({ shown }) { @@ -8830,7 +8852,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); const firstTurns = useSessions((list) => { -@@ -5537,7 +10126,7 @@ +@@ -5537,7 +10140,7 @@ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -8839,7 +8861,7 @@ 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 +10137,41 @@ +@@ -5548,52 +10151,41 @@ const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -8889,14 +8911,15 @@ diff --git a/client.js b/client.js - (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), - (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) +- ), +- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), +- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), +- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + bot ? (0, import_react18.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, + (0, import_react18.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_react18.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react18.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react18.createElement)(GoArrow)) - ), -- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), -- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ++ ), + (0, import_react18.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), + (0, import_react18.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react18.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), @@ -8911,7 +8934,7 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5642,7 +10220,7 @@ +@@ -5642,7 +10234,7 @@ }, []); return view; } @@ -8920,7 +8943,7 @@ diff --git a/client.js b/client.js const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -@@ -5658,18 +10236,22 @@ +@@ -5658,18 +10250,22 @@ }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -8948,7 +8971,7 @@ diff --git a/client.js b/client.js const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +10259,16 @@ +@@ -5677,16 +10273,16 @@ }, [showDown]); if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); @@ -8969,7 +8992,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +10276,7 @@ +@@ -5694,7 +10290,7 @@ event.stopPropagation(); onDismiss(); } @@ -8978,7 +9001,7 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5703,9 +10285,9 @@ +@@ -5703,9 +10299,9 @@ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); @@ -8991,7 +9014,7 @@ diff --git a/client.js b/client.js null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5714,19 +10296,135 @@ +@@ -5714,19 +10310,135 @@ ); } @@ -9135,7 +9158,7 @@ diff --git a/client.js b/client.js if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5745,10 +10443,10 @@ +@@ -5745,10 +10457,10 @@ let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; @@ -9149,7 +9172,7 @@ diff --git a/client.js b/client.js } if (interim) latest.current(interim, false); }; -@@ -5774,19 +10472,38 @@ +@@ -5774,19 +10486,38 @@ const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -9193,7 +9216,7 @@ diff --git a/client.js b/client.js const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); if (box instanceof HTMLElement) { box.focus(); -@@ -5797,10 +10514,10 @@ +@@ -5797,10 +10528,10 @@ }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -9207,7 +9230,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +10525,16 @@ +@@ -5808,16 +10539,16 @@ title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -9228,7 +9251,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +10542,17 @@ +@@ -5825,17 +10556,17 @@ return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -9251,7 +9274,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +10593,10 @@ +@@ -5876,10 +10607,10 @@ actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -9265,7 +9288,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +10606,8 @@ +@@ -5889,8 +10620,8 @@ "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -9276,7 +9299,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +10617,9 @@ +@@ -5900,9 +10631,9 @@ style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -9289,7 +9312,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +10639,57 @@ +@@ -5922,15 +10653,57 @@ function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -9351,7 +9374,7 @@ diff --git a/client.js b/client.js function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; -@@ -5959,14 +10718,31 @@ +@@ -5959,14 +10732,31 @@ order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -9387,7 +9410,7 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +10771,83 @@ +@@ -5995,6 +10785,83 @@ return [bots, chats]; } @@ -9471,7 +9494,7 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6008,8 +10861,8 @@ +@@ -6008,8 +10875,8 @@ function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); @@ -9482,7 +9505,7 @@ diff --git a/client.js b/client.js }; ascii(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); -@@ -6030,9 +10883,9 @@ +@@ -6030,9 +10897,9 @@ return bytes; } function base64Of(bytes) { @@ -9495,7 +9518,7 @@ diff --git a/client.js b/client.js } var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); async function openCapture() { -@@ -6121,233 +10974,9 @@ +@@ -6121,233 +10988,9 @@ }; } @@ -9731,7 +9754,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS = 120; function groupModels(models) { const groups = /* @__PURE__ */ new Map(); -@@ -6367,17 +10996,10 @@ +@@ -6367,17 +11010,10 @@ }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -9751,7 +9774,7 @@ diff --git a/client.js b/client.js setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +11024,12 @@ +@@ -6402,12 +11038,12 @@ ]; } function EffortRows({ reasoning, current, onPick }) { @@ -9769,7 +9792,7 @@ diff --git a/client.js b/client.js "button", { key: row.effort ?? "default", -@@ -6416,17 +11038,17 @@ +@@ -6416,17 +11052,17 @@ "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -9793,7 +9816,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +11058,20 @@ +@@ -6436,20 +11072,20 @@ title: model.ref, onClick: () => onPick(model.ref) }, @@ -9822,7 +9845,7 @@ diff --git a/client.js b/client.js if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +11106,7 @@ +@@ -6484,6 +11120,7 @@ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -9830,7 +9853,7 @@ diff --git a/client.js b/client.js const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +11134,10 @@ +@@ -6511,10 +11148,10 @@ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -9844,7 +9867,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6527,37 +11150,38 @@ +@@ -6527,37 +11164,38 @@ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -9897,7 +9920,7 @@ diff --git a/client.js b/client.js const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; -@@ -6569,13 +11193,13 @@ +@@ -6569,13 +11207,13 @@ void actions.refreshModels?.(); }).finally(() => setBusy("")); }; @@ -9916,7 +9939,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +11208,115 @@ +@@ -6584,50 +11222,115 @@ "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -10048,7 +10071,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +11328,16 @@ +@@ -6639,6 +11342,16 @@ }); } var MODEL_CSS = ` @@ -10065,7 +10088,7 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6675,18 +11374,96 @@ +@@ -6675,18 +11388,96 @@ @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} `; @@ -10170,7 +10193,7 @@ diff --git a/client.js b/client.js const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +11496,35 @@ +@@ -6719,35 +11510,35 @@ }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -10222,7 +10245,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +11536,33 @@ +@@ -6759,33 +11550,33 @@ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -10272,7 +10295,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6798,19 +11575,19 @@ +@@ -6798,19 +11589,19 @@ title: value, onClick: toggle }, @@ -10298,7 +10321,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +11607,8 @@ +@@ -6830,8 +11621,8 @@ `; // src/client/schedules.js @@ -10309,7 +10332,7 @@ diff --git a/client.js b/client.js var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +11616,8 @@ +@@ -6839,8 +11630,8 @@ for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -10320,7 +10343,7 @@ diff --git a/client.js b/client.js listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +11625,7 @@ +@@ -6848,7 +11639,7 @@ if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -10329,7 +10352,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6873,7 +11650,8 @@ +@@ -6873,7 +11664,8 @@ function everyText(seconds) { if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); @@ -10339,7 +10362,7 @@ diff --git a/client.js b/client.js } function timingText(task) { const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; -@@ -6887,8 +11665,8 @@ +@@ -6887,8 +11679,8 @@ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); @@ -10350,7 +10373,7 @@ diff --git a/client.js b/client.js } case "cron": return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -@@ -6896,11 +11674,25 @@ +@@ -6896,11 +11688,25 @@ return ""; } } @@ -10381,7 +10404,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +11705,49 @@ +@@ -6913,20 +11719,49 @@ setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -10440,7 +10463,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +11755,7 @@ +@@ -6934,7 +11769,7 @@ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -10449,7 +10472,7 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6944,27 +11765,49 @@ +@@ -6944,27 +11779,49 @@ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; @@ -10516,7 +10539,7 @@ diff --git a/client.js b/client.js setBusy(true); setError(""); let when3; -@@ -6975,151 +11818,298 @@ +@@ -6975,151 +11832,298 @@ setBusy(false); return; } @@ -10754,7 +10777,7 @@ diff --git a/client.js b/client.js + (0, import_react23.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; -+} + } +function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { + const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; + const [copying, setCopying] = (0, import_react23.useState)(null); @@ -10803,7 +10826,7 @@ diff --git a/client.js b/client.js + (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + error ? (0, import_react23.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + ); - } ++} function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); + const [editing, setEditing] = (0, import_react23.useState)(null); @@ -10877,7 +10900,7 @@ diff --git a/client.js b/client.js .bt-task[data-done]{opacity:.6} .bt-task[data-busy]{opacity:.4} .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} -@@ -7127,6 +12117,12 @@ +@@ -7127,6 +12131,12 @@ .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} @@ -10890,7 +10913,7 @@ diff --git a/client.js b/client.js .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -@@ -7158,12 +12154,12 @@ +@@ -7158,12 +12168,12 @@ `; // src/client/capability-settings.js @@ -10906,7 +10929,7 @@ diff --git a/client.js b/client.js } catch { throw new Error(t("{label} must be valid JSON.", { label })); } -@@ -7172,11 +12168,11 @@ +@@ -7172,11 +12182,11 @@ }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { @@ -10921,7 +10944,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +12184,7 @@ +@@ -7188,7 +12198,7 @@ ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -10930,7 +10953,7 @@ diff --git a/client.js b/client.js const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7199,7 +12195,7 @@ +@@ -7199,7 +12209,7 @@ if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -10939,7 +10962,7 @@ diff --git a/client.js b/client.js payload.env = parseMap(draft.envText, t("Environment")); } else if (draft.kind === "mcp") { payload.transport = "streamable-http"; -@@ -7219,95 +12215,95 @@ +@@ -7219,95 +12229,95 @@ } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -11075,7 +11098,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +12326,20 @@ +@@ -7330,20 +12340,20 @@ setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -11103,7 +11126,7 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +12355,10 @@ +@@ -7359,10 +12369,10 @@ const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -11116,7 +11139,7 @@ diff --git a/client.js b/client.js key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +12368,55 @@ +@@ -7372,9 +12382,55 @@ ); } @@ -11174,7 +11197,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,13 +12426,13 @@ +@@ -7384,13 +12440,13 @@ for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -11191,7 +11214,7 @@ diff --git a/client.js b/client.js if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); -@@ -7404,16 +12446,16 @@ +@@ -7404,16 +12460,16 @@ function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11214,7 +11237,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +12470,45 @@ +@@ -7428,45 +12484,45 @@ return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -11279,7 +11302,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +12520,9 @@ +@@ -7478,9 +12534,9 @@ return groups; }, []); function EntryRow({ entry, remove }) { @@ -11292,7 +11315,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +12537,16 @@ +@@ -7495,16 +12551,16 @@ setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -11314,7 +11337,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +12554,7 @@ +@@ -7512,7 +12568,7 @@ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -11323,7 +11346,7 @@ diff --git a/client.js b/client.js ); } function Entries({ bot, view, remove }) { -@@ -7521,82 +12563,82 @@ +@@ -7521,82 +12577,82 @@ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; @@ -11441,7 +11464,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,9 +12648,9 @@ +@@ -7606,9 +12662,9 @@ }, [botId]); return { view, error, load, show }; } @@ -11454,7 +11477,7 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +12658,18 @@ +@@ -7616,18 +12672,18 @@ } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -11483,7 +11506,7 @@ diff --git a/client.js b/client.js if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7671,7 +12713,7 @@ +@@ -7671,7 +12727,7 @@ }; const ask = async (event) => { event.preventDefault(); @@ -11492,7 +11515,7 @@ diff --git a/client.js b/client.js if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); -@@ -7689,74 +12731,106 @@ +@@ -7689,65 +12745,65 @@ const when3 = view?.summary ? ago(view.summary.at, now) : null; const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; const busy = answer?.pending === true; @@ -11585,12 +11608,12 @@ diff --git a/client.js b/client.js ) ) ) - ) -+ ); -+} -+ -+// src/client/standing-approvals.js -+var import_react27 = require("react"); +@@ -7755,8 +12811,40 @@ + ); + } + ++// src/client/standing-approvals.js ++var import_react27 = require("react"); +var standingOf = (target, roster) => { + const me = roster?.self?.memberId; + return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me); @@ -11618,16 +11641,16 @@ diff --git a/client.js b/client.js + onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind)) + }, t("Remove")) + )) - ); - } - ++ ); ++} ++ // src/client/bot-settings.js -var import_react18 = require("react"); +var import_react28 = require("react"); var capitalize = (word) => word[0].toUpperCase() + word.slice(1); async function avatarImage(file) { if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); -@@ -7780,8 +12854,8 @@ +@@ -7780,8 +12868,8 @@ throw new Error(t("That image is too large")); } function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { @@ -11638,7 +11661,7 @@ diff --git a/client.js b/client.js const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +12866,7 @@ +@@ -7792,7 +12880,7 @@ if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -11647,7 +11670,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, -@@ -7813,29 +12887,29 @@ +@@ -7813,29 +12901,29 @@ }); } function Row({ label, hint, stack = false, children }) { @@ -11686,7 +11709,7 @@ diff --git a/client.js b/client.js ); } function modelHint(bot, roster) { -@@ -7846,22 +12920,22 @@ +@@ -7846,22 +12934,22 @@ return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -11714,7 +11737,7 @@ diff --git a/client.js b/client.js key: id, type: "button", className: "bt-shape", -@@ -7869,8 +12943,8 @@ +@@ -7869,8 +12957,8 @@ title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -11725,7 +11748,7 @@ diff --git a/client.js b/client.js key: color, type: "button", className: "bt-color", -@@ -7878,19 +12952,19 @@ +@@ -7878,19 +12966,19 @@ title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -11751,7 +11774,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); -@@ -7908,7 +12982,7 @@ +@@ -7908,7 +12996,7 @@ window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -11760,7 +11783,7 @@ diff --git a/client.js b/client.js if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +12996,32 @@ +@@ -7922,32 +13010,32 @@ }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -11801,7 +11824,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-dropzone", -@@ -7963,15 +13037,15 @@ +@@ -7963,15 +13051,15 @@ upload(event.dataTransfer.files?.[0]); } }, @@ -11823,7 +11846,7 @@ diff --git a/client.js b/client.js const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,26 +13056,27 @@ +@@ -7982,26 +13070,27 @@ function MemoryCard({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11859,7 +11882,7 @@ diff --git a/client.js b/client.js const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; -@@ -8011,112 +13086,116 @@ +@@ -8011,112 +13100,116 @@ if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -12025,7 +12048,7 @@ diff --git a/client.js b/client.js ) ) ); -@@ -8130,9 +13209,17 @@ +@@ -8130,9 +13223,17 @@ .bt-bs .bt-avedit{left:0;transform:none} .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} @@ -12044,7 +12067,7 @@ diff --git a/client.js b/client.js .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)} .bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} -@@ -8153,7 +13240,658 @@ +@@ -8153,8 +13254,659 @@ .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}} @@ -12057,7 +12080,7 @@ diff --git a/client.js b/client.js + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} `; -+ + +// src/client/group-settings.js +var import_react29 = require("react"); +var ROOM_MODES = [ @@ -12700,10 +12723,11 @@ diff --git a/client.js b/client.js + ) + ); +} - ++ // src/client/bot-draft.js function freshBotName(roster, base = "New Bot") { -@@ -8165,36 +13903,48 @@ + const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase())); +@@ -8165,36 +13917,48 @@ } function startModel(roster) { const models = roster.models ?? []; @@ -12767,7 +12791,7 @@ diff --git a/client.js b/client.js if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8206,8 +13956,8 @@ +@@ -8206,8 +13970,8 @@ setError(""); setBusy(true); try { @@ -12778,7 +12802,7 @@ diff --git a/client.js b/client.js } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); -@@ -8219,25 +13969,25 @@ +@@ -8219,25 +13983,25 @@ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -12812,7 +12836,7 @@ diff --git a/client.js b/client.js look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +13999,27 @@ +@@ -8249,27 +14013,27 @@ setLook((current) => ({ ...current, color })); } }), @@ -12848,7 +12872,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,73 +14034,111 @@ +@@ -8284,75 +14048,113 @@ } } }), @@ -12959,8 +12983,8 @@ diff --git a/client.js b/client.js + setFix(ungrantedLocalFix(failure?.details) ?? null); } finally { setBusy(false); -+ } -+ }; + } + }; + const leaveOut = (id) => { + if (id === admin) setAdmin(null); + setPicked((current) => current.filter((other) => other !== id)); @@ -12987,10 +13011,12 @@ diff --git a/client.js b/client.js + } else { + setInviteLink(link); + setFixNote(t("Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.", { name: bot.name })); - } - }; ++ } ++ }; const pick2 = (bot) => { -@@ -8386,71 +14174,104 @@ + setPicked((current) => [...current, bot.id]); + setQuery(""); +@@ -8386,71 +14188,104 @@ }; const created = (bot) => { close(); @@ -13115,7 +13141,7 @@ diff --git a/client.js b/client.js canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); -@@ -8467,33 +14288,41 @@ +@@ -8467,33 +14302,41 @@ if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -13172,7 +13198,7 @@ diff --git a/client.js b/client.js ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +14346,20 @@ +@@ -8517,20 +14360,20 @@ } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -13198,7 +13224,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +14371,36 @@ +@@ -8542,21 +14385,36 @@ onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -13244,7 +13270,7 @@ diff --git a/client.js b/client.js rows: 1, value: message, placeholder, -@@ -8569,7 +14413,7 @@ +@@ -8569,7 +14427,7 @@ } } }), @@ -13253,7 +13279,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +14421,41 @@ +@@ -8577,18 +14435,41 @@ } // src/client/exchange.js @@ -13299,7 +13325,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,49 +14479,42 @@ +@@ -8612,49 +14493,42 @@ const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -13363,7 +13389,7 @@ diff --git a/client.js b/client.js var VOICE_LABELS = { listening: "Listening", thinking: "Thinking", -@@ -8665,14 +14525,14 @@ +@@ -8665,14 +14539,14 @@ }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -13381,7 +13407,7 @@ diff --git a/client.js b/client.js if (stopped) { media.getTracks().forEach((track) => track.stop()); return; -@@ -8716,18 +14576,18 @@ +@@ -8716,18 +14590,18 @@ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -13411,7 +13437,7 @@ diff --git a/client.js b/client.js const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8735,24 +14595,24 @@ +@@ -8735,24 +14609,24 @@ actions.closeVoice(); }; useEscape(close); @@ -13444,7 +13470,7 @@ diff --git a/client.js b/client.js if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +14627,16 @@ +@@ -8767,16 +14641,16 @@ }; }, [speech]); const listen = phase === "listening"; @@ -13464,7 +13490,7 @@ diff --git a/client.js b/client.js if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8789,9 +14649,9 @@ +@@ -8789,9 +14663,9 @@ setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); @@ -13476,7 +13502,7 @@ diff --git a/client.js b/client.js else setPhase("listening"); } catch (error) { if (turn.current !== own) return; -@@ -8838,12 +14698,12 @@ +@@ -8838,12 +14712,12 @@ setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -13493,7 +13519,7 @@ diff --git a/client.js b/client.js setSaid(spoken); setHeard(""); if (!window.speechSynthesis || !spoken) { -@@ -8858,7 +14718,7 @@ +@@ -8858,7 +14732,7 @@ window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -13502,7 +13528,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,291 +14735,641 @@ +@@ -8875,291 +14749,641 @@ const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -13583,24 +13609,7 @@ diff --git a/client.js b/client.js + const code = failure && typeof failure === "object" ? String(failure.code ?? "") : ""; + const message = typeof failure === "string" ? failure : String(failure?.message ?? ""); + return code === "bot/not-found" || /Unknown FlyBot endpoint workflow\//i.test(message); - } --function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { -- const [adding, setAdding] = (0, import_react23.useState)(false); -- const [confirming, setConfirming] = (0, import_react23.useState)(false); -- (0, import_react23.useEffect)(() => { -- if (!confirming) return void 0; -- const timer = setTimeout(() => setConfirming(false), 4e3); -- return () => clearTimeout(timer); -- }, [confirming]); -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); -- const admin = roster.byId[room2.admin]; -- const mode = shownMode(room2, roster); -- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; -- const update = (patch) => run(actions.updateRoom(room2.id, patch)); -- const openBot = (id) => { -- actions.closeSettings(); -- actions.openSession(id); ++} +function workflowCallError(failure, fallback = "Could not read workflows") { + if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT; + if (typeof failure === "string" && failure.trim()) return failure.trim(); @@ -13631,7 +13640,24 @@ diff --git a/client.js b/client.js + const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs); + Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null })); + }); -+} + } +-function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +- const [adding, setAdding] = (0, import_react23.useState)(false); +- const [confirming, setConfirming] = (0, import_react23.useState)(false); +- (0, import_react23.useEffect)(() => { +- if (!confirming) return void 0; +- const timer = setTimeout(() => setConfirming(false), 4e3); +- return () => clearTimeout(timer); +- }, [confirming]); +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); +- const admin = roster.byId[room2.admin]; +- const mode = shownMode(room2, roster); +- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; +- const update = (patch) => run(actions.updateRoom(room2.id, patch)); +- const openBot = (id) => { +- actions.closeSettings(); +- actions.openSession(id); +function moveNode(nodes, id, x, y) { + return nodes.map((node) => node.id === id ? { ...node, x, y } : node); +} @@ -13984,13 +14010,28 @@ diff --git a/client.js b/client.js - Row, - { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, - (0, import_react23.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) -- ) ++ (0, import_react35.createElement)( ++ "section", ++ { className: "bt-set-section" }, ++ (0, import_react35.createElement)("h3", null, t("Yours")), ++ (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")), ++ (0, import_react35.createElement)( ++ "div", ++ { className: "bt-set-card" }, ++ yours.length === 0 ? (0, import_react35.createElement)("div", { className: "bt-set-row" }, (0, import_react35.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react35.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete })) ++ ) + ) - ), - (0, import_react23.createElement)( - Card, - { title: t("Who answers") }, - (0, import_react23.createElement)( -- "div", ++ ) : null, ++ graph ? (0, import_react35.createElement)( ++ "div", ++ { className: "bt-wf-editor" }, ++ (0, import_react35.createElement)( + "div", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react23.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react23.createElement)("button", { - key: id, @@ -14009,7 +14050,41 @@ diff --git a/client.js b/client.js - Card, - { title: t("Working folder") }, - (0, import_react23.createElement)( -- "div", ++ { className: "bt-wf-actions" }, ++ (0, import_react35.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")), ++ showUse ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null, ++ opened?.source === "own" ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null ++ ), ++ (0, import_react35.createElement)("h3", { className: "bt-wf-title" }, graph.name), ++ graph.description ? (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null, ++ (graph.nodes ?? []).length === 0 ? (0, import_react35.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react35.createElement)(GraphCanvas, { ++ graph, ++ roster, ++ onCommit, ++ editing: opened?.source === "own" && editing === true ++ }) ++ ) : null ++ ); ++} ++function RenameModal({ workflow, onClose, onConfirm }) { ++ const [name, setName] = (0, import_react35.useState)(workflow?.name ?? ""); ++ return (0, import_react35.createElement)( ++ "div", ++ { className: "bt-modal-mask", onClick: (event) => { ++ if (event.target === event.currentTarget) onClose?.(); ++ } }, ++ (0, import_react35.createElement)( ++ "div", ++ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") }, ++ (0, import_react35.createElement)("h3", null, t("Rename workflow")), ++ (0, import_react35.createElement)( ++ "label", ++ { className: "bt-field" }, ++ t("Name"), ++ (0, import_react35.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true }) ++ ), ++ (0, import_react35.createElement)( + "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), - (0, import_react23.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) @@ -14021,13 +14096,7 @@ diff --git a/client.js b/client.js - members.map((member) => { - const leads = room2.admin === member.id; - return (0, import_react23.createElement)( -+ (0, import_react35.createElement)( -+ "section", -+ { className: "bt-set-section" }, -+ (0, import_react35.createElement)("h3", null, t("Yours")), -+ (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")), -+ (0, import_react35.createElement)( - "div", +- "div", - { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, - (0, import_react23.createElement)( - "button", @@ -14079,31 +14148,11 @@ diff --git a/client.js b/client.js - (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) - ), - (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) -+ { className: "bt-set-card" }, -+ yours.length === 0 ? (0, import_react35.createElement)("div", { className: "bt-set-row" }, (0, import_react35.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react35.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete })) -+ ) ++ { className: "bt-modal-actions" }, ++ (0, import_react35.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")), ++ (0, import_react35.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename")) ) -- ) -+ ) : null, -+ graph ? (0, import_react35.createElement)( -+ "div", -+ { className: "bt-wf-editor" }, -+ (0, import_react35.createElement)( -+ "div", -+ { className: "bt-wf-actions" }, -+ (0, import_react35.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")), -+ showUse ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null, -+ opened?.source === "own" ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null -+ ), -+ (0, import_react35.createElement)("h3", { className: "bt-wf-title" }, graph.name), -+ graph.description ? (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null, -+ (graph.nodes ?? []).length === 0 ? (0, import_react35.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react35.createElement)(GraphCanvas, { -+ graph, -+ roster, -+ onCommit, -+ editing: opened?.source === "own" && editing === true -+ }) -+ ) : null + ) ); } -function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { @@ -14135,63 +14184,41 @@ diff --git a/client.js b/client.js - onClick - }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); - return (0, import_react23.createElement)( -+function RenameModal({ workflow, onClose, onConfirm }) { -+ const [name, setName] = (0, import_react35.useState)(workflow?.name ?? ""); ++function DeleteModal({ workflow, onClose, onConfirm }) { + return (0, import_react35.createElement)( "div", - { className: "bt-set-card bt-ng" }, - (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), +- (0, import_react23.createElement)( + { className: "bt-modal-mask", onClick: (event) => { + if (event.target === event.currentTarget) onClose?.(); + } }, + (0, import_react35.createElement)( - "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), -- (0, import_react23.createElement)( -+ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") }, -+ (0, import_react35.createElement)("h3", null, t("Rename workflow")), -+ (0, import_react35.createElement)( -+ "label", -+ { className: "bt-field" }, -+ t("Name"), -+ (0, import_react35.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true }) -+ ), ++ "div", ++ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") }, ++ (0, import_react35.createElement)("h3", null, t("Delete workflow")), ++ (0, import_react35.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })), + (0, import_react35.createElement)( "div", - { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, - roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) + { className: "bt-modal-actions" }, + (0, import_react35.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")), -+ (0, import_react35.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename")) ++ (0, import_react35.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete")) ) - ), - admin === null ? null : (0, import_react23.createElement)( -+ ) -+ ); -+} -+function DeleteModal({ workflow, onClose, onConfirm }) { -+ return (0, import_react35.createElement)( -+ "div", -+ { className: "bt-modal-mask", onClick: (event) => { -+ if (event.target === event.currentTarget) onClose?.(); -+ } }, -+ (0, import_react35.createElement)( - "div", +- "div", - { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), - (0, import_react23.createElement)( -+ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") }, -+ (0, import_react35.createElement)("h3", null, t("Delete workflow")), -+ (0, import_react35.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })), -+ (0, import_react35.createElement)( - "div", +- "div", - { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, - roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) -+ { className: "bt-modal-actions" }, -+ (0, import_react35.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")), -+ (0, import_react35.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete")) - ) +- ) - ), - admin === null || members.length === 0 ? null : (0, import_react23.createElement)( - "label", @@ -14379,7 +14406,7 @@ diff --git a/client.js b/client.js // src/client/usage-data.js var HOUR = 0; -@@ -9277,13 +15487,13 @@ +@@ -9277,13 +15501,13 @@ } return result; } @@ -14397,7 +14424,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +15504,11 @@ +@@ -9294,11 +15518,11 @@ } // src/client/usage-page.js @@ -14411,7 +14438,7 @@ diff --git a/client.js b/client.js var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id]; var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit]; var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"]; -@@ -9341,9 +15551,9 @@ +@@ -9341,9 +15565,9 @@ return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -14424,7 +14451,7 @@ diff --git a/client.js b/client.js if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +15573,9 @@ +@@ -9363,9 +15587,9 @@ }, [value]); return shown; } @@ -14436,7 +14463,7 @@ diff --git a/client.js b/client.js className: "bt-us-model", "aria-hidden": true, style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` } -@@ -9382,7 +15592,7 @@ +@@ -9382,7 +15606,7 @@ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -14445,7 +14472,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +15602,7 @@ +@@ -9392,7 +15616,7 @@ name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -14454,7 +14481,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9411,33 +15621,33 @@ +@@ -9411,33 +15635,33 @@ return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { @@ -14499,7 +14526,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +15665,83 @@ +@@ -9455,83 +15679,83 @@ return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max); } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { @@ -14611,7 +14638,7 @@ diff --git a/client.js b/client.js onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +15750,69 @@ +@@ -9540,69 +15764,69 @@ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -14709,7 +14736,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +15838,17 @@ +@@ -9628,17 +15852,17 @@ const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -14730,7 +14757,7 @@ diff --git a/client.js b/client.js key, type: "button", className: "bt-us-day", -@@ -9654,15 +15864,15 @@ +@@ -9654,15 +15878,15 @@ "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -14752,7 +14779,7 @@ diff --git a/client.js b/client.js ); }; const shortcuts = [ -@@ -9671,30 +15881,30 @@ +@@ -9671,30 +15895,30 @@ ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today] ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; @@ -14791,7 +14818,7 @@ diff --git a/client.js b/client.js const today = hourMs(`${view.now.slice(0, 10)} 00`); const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS; const from = dayKey(first); -@@ -9710,13 +15920,13 @@ +@@ -9710,13 +15934,13 @@ for (let week = 0; week < HEAT_WEEKS; week += 1) { const monday = first + week * 7 * DAY_MS; if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) { @@ -14807,7 +14834,7 @@ diff --git a/client.js b/client.js key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +15934,7 @@ +@@ -9724,7 +15948,7 @@ "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -14816,7 +14843,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +15943,61 @@ +@@ -9733,61 +15957,61 @@ } : null; const active = [...daily.values()].filter((value) => value > 0).length; const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active }); @@ -14904,7 +14931,7 @@ diff --git a/client.js b/client.js let stopped = false; let timer; const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => { -@@ -9797,7 +16007,7 @@ +@@ -9797,7 +16021,7 @@ const value = await actions.usage(); await resized; if (stopped) return; @@ -14913,7 +14940,7 @@ diff --git a/client.js b/client.js setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +16024,7 @@ +@@ -9814,7 +16038,7 @@ }, []); const today = view?.now.slice(0, 10); const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId); @@ -14922,7 +14949,7 @@ diff --git a/client.js b/client.js if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +16033,7 @@ +@@ -9823,7 +16047,7 @@ const models2 = view.models.map((model, at) => { const name = model.name || model.model || t("Unknown model"); const paint = modelPaint.get(at) ?? "var(--bt-us-rest)"; @@ -14931,7 +14958,7 @@ diff --git a/client.js b/client.js }); const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1; const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1; -@@ -9871,23 +16081,23 @@ +@@ -9871,23 +16095,23 @@ }); return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 }; }, [view, rangeId, span, by, focusId, roster]); @@ -14963,7 +14990,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +16122,7 @@ +@@ -9912,7 +16136,7 @@ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -14972,7 +14999,7 @@ diff --git a/client.js b/client.js key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +16134,7 @@ +@@ -9924,7 +16148,7 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint), onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); @@ -14981,7 +15008,7 @@ diff --git a/client.js b/client.js key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +16146,30 @@ +@@ -9936,30 +16160,30 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint), onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); @@ -15022,7 +15049,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +16178,39 @@ +@@ -9968,39 +16192,39 @@ const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -15076,7 +15103,7 @@ diff --git a/client.js b/client.js body ); } -@@ -10150,33 +16360,216 @@ +@@ -10150,33 +16374,216 @@ @keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}} @keyframes bt-us-fade{from{opacity:0}} @keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}} @@ -15304,7 +15331,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,83 +16577,195 @@ +@@ -10184,83 +16591,195 @@ after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -15415,23 +15442,21 @@ diff --git a/client.js b/client.js + on && bots ? (0, import_react37.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, + on && channel && connector.mode === "app" ? (0, import_react37.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, + on && canPost ? (0, import_react37.createElement)( - "div", - { className: "bt-connector-form" }, -- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), -- (0, import_react25.createElement)( ++ "div", ++ { className: "bt-connector-form" }, + (0, import_react37.createElement)("label", { className: "bt-q-field" }, (0, import_react37.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })), + (0, import_react37.createElement)( - "div", - { className: "bt-connector-form-row" }, -- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ "div", ++ { className: "bt-connector-form-row" }, + (0, import_react37.createElement)("span", { className: "bt-connector-link" }), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) + ) + ) : null, + !on && form ? (0, import_react37.createElement)( -+ "div", -+ { className: "bt-connector-form" }, + "div", + { className: "bt-connector-form" }, +- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), +- (0, import_react25.createElement)( + channel ? (0, import_react37.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react37.createElement)("button", { + key: entry.id, + type: "button", @@ -15453,8 +15478,10 @@ diff --git a/client.js b/client.js + })) : (0, import_react37.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react37.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + gateway ? (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, + (0, import_react37.createElement)( -+ "div", -+ { className: "bt-connector-form-row" }, + "div", + { className: "bt-connector-form-row" }, +- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (0, import_react37.createElement)( + "a", + { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, @@ -15530,7 +15557,7 @@ diff --git a/client.js b/client.js const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" }); -@@ -10270,18 +16775,18 @@ +@@ -10270,18 +16789,18 @@ if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -15556,7 +15583,7 @@ diff --git a/client.js b/client.js void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +16794,7 @@ +@@ -10289,7 +16808,7 @@ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -15565,7 +15592,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10307,8 +16812,8 @@ +@@ -10307,8 +16826,8 @@ if (!inline && !copied) setPreview(true); })); }; @@ -15576,7 +15603,7 @@ diff --git a/client.js b/client.js setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it.")); })); const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log.")))); -@@ -10317,59 +16822,59 @@ +@@ -10317,59 +16836,59 @@ const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -15664,7 +15691,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +16882,8 @@ +@@ -10377,8 +16896,8 @@ var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -15675,7 +15702,7 @@ diff --git a/client.js b/client.js if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +16894,16 @@ +@@ -10389,15 +16908,16 @@ }; }, [toast?.token]); if (!toast) return null; @@ -15697,7 +15724,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10416,47 +16922,56 @@ +@@ -10416,47 +16936,56 @@ .bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word} .bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px} .bt-event-error{color:#e02135} @@ -15768,7 +15795,7 @@ diff --git a/client.js b/client.js let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,87 +16981,72 @@ +@@ -10466,87 +16995,72 @@ live = false; }; }, []); @@ -15879,7 +15906,7 @@ diff --git a/client.js b/client.js .bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .bt-about-name{font-size:16px;line-height:22px;font-weight:600} .bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -@@ -10569,54 +17069,59 @@ +@@ -10569,54 +17083,59 @@ `; // src/client/settings.js @@ -15888,8 +15915,8 @@ diff --git a/client.js b/client.js var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1); -var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About DS Bot"]]; -var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; -+var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; -+var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; ++var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; ++var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; function SetSection({ title, children }) { - return (0, import_react28.createElement)("section", { className: "bt-set-section" }, (0, import_react28.createElement)("h3", null, title), (0, import_react28.createElement)("div", { className: "bt-set-card" }, children)); + return (0, import_react40.createElement)("section", { className: "bt-set-section" }, (0, import_react40.createElement)("h3", null, title), (0, import_react40.createElement)("div", { className: "bt-set-card" }, children)); @@ -15957,7 +15984,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +17131,86 @@ +@@ -10626,75 +17145,86 @@ style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -16067,7 +16094,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +17222,69 @@ +@@ -10706,69 +17236,69 @@ ); } function BotsPage({ roster, actions, run }) { @@ -16167,7 +16194,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +17295,113 @@ +@@ -10779,104 +17309,250 @@ }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -16269,6 +16296,7 @@ diff --git a/client.js b/client.js + shown === "bots" && !bot ? (0, import_react40.createElement)(BotsPage, { roster, actions, run }) : null, + room2 ? (0, import_react40.createElement)("div", { className: "bt-settings-form" }, (0, import_react40.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, + shown === "groups" && !room2 ? (0, import_react40.createElement)(GroupsPage, { roster, actions, run }) : null, ++ shown === "projects" ? (0, import_react40.createElement)(ProjectsPage, { roster, actions, run, call: call2 }) : null, + shown === "connectors" ? (0, import_react40.createElement)(ConnectorsPage, { roster, actions }) : null, + shown === "workflows" ? (0, import_react40.createElement)(WorkflowsPage, { call: call2, roster }) : null, + pluginPage ? (0, import_react40.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, @@ -16292,6 +16320,142 @@ diff --git a/client.js b/client.js ) ); } ++var openProjectBoard = (id) => { ++ if (typeof window !== "undefined") window.location.hash = `#project=${id}`; ++}; ++var projectMembers = (roster) => [ ++ ...(roster?.bots ?? []).filter((bot) => !bot.remote).map((bot) => ({ kind: "bot", id: bot.id, name: bot.name, projectId: bot.projectId ?? null })), ++ ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null })) ++]; ++function ProjectsList({ projects, roster, call: call4, reload }) { ++ const members = projectMembers(roster); ++ const bind = async (choice, project) => { ++ const [kind, id] = String(choice ?? "").split(":"); ++ if (!kind || !id) return; ++ await call4("project-bind", { kind, id, projectId: project.id }); ++ await reload?.(); ++ }; ++ const unbind = async (member) => { ++ await call4("project-bind", { kind: member.kind, id: member.id, projectId: null }); ++ await reload?.(); ++ }; ++ const bound2 = members.filter((member) => member.projectId !== null && member.projectId !== void 0); ++ return [ ++ (0, import_react40.createElement)( ++ SetSection, ++ { key: "projects", title: t("Projects · {count}", { count: projects.length }) }, ++ projects.length === 0 ? (0, import_react40.createElement)("div", { className: "bt-set-row" }, (0, import_react40.createElement)("div", { className: "bt-set-copy" }, (0, import_react40.createElement)("span", null, t("No Project yet — create one.")))) : projects.map((project) => (0, import_react40.createElement)( ++ SetRow, ++ { ++ key: project.id, ++ label: project.name, ++ hint: project.workDir === null || project.workDir === void 0 ? void 0 : project.workDir ++ }, ++ (0, import_react40.createElement)( ++ "div", ++ { className: "bt-project-row-controls" }, ++ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")), ++ (0, import_react40.createElement)( ++ "select", ++ { ++ className: "bt-select", ++ "aria-label": t("Bind to {name}", { name: project.name }), ++ value: "", ++ onChange: (event) => { ++ void bind(event.target.value, project); ++ } ++ }, ++ (0, import_react40.createElement)("option", { value: "" }, t("Bind a Bot or group")), ++ members.map((member) => (0, import_react40.createElement)( ++ "option", ++ { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` }, ++ `${member.kind === "group" ? t("Group") : t("Bot")} · ${member.name}` ++ )) ++ ) ++ ) ++ )) ++ ), ++ bound2.length === 0 ? null : (0, import_react40.createElement)( ++ SetSection, ++ { key: "bindings", title: t("Bindings") }, ++ bound2.map((member) => (0, import_react40.createElement)( ++ SetRow, ++ { ++ key: `${member.kind}:${member.id}`, ++ label: member.name, ++ hint: member.kind === "group" ? t("Group") : t("Bot") ++ }, ++ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { ++ void unbind(member); ++ } }, t("Unbind")) ++ )) ++ ) ++ ]; ++} ++function ProjectsPage({ roster, actions, run, call: call4 }) { ++ const [state, setState] = (0, import_react40.useState)({ projects: [], error: null }); ++ const [name, setName] = (0, import_react40.useState)(""); ++ const [workDir, setWorkDir] = (0, import_react40.useState)(null); ++ const reload = (0, import_react40.useCallback)(async () => { ++ const listing = await call4("projects"); ++ setState({ projects: listing?.projects ?? [], error: null }); ++ }, [call4]); ++ (0, import_react40.useEffect)(() => { ++ void reload().catch((error) => setState({ projects: [], error: error?.message ?? String(error) })); ++ }, [reload]); ++ const create = async () => { ++ const trimmed3 = name.trim(); ++ if (trimmed3 === "") return; ++ const body = { name: trimmed3 }; ++ if (workDir !== null && workDir !== "") body.workDir = workDir; ++ try { ++ await call4("project-create", body); ++ setName(""); ++ setWorkDir(null); ++ await reload(); ++ } catch (error) { ++ setState({ projects: state.projects, error: error?.message ?? String(error) }); ++ } ++ }; ++ return [ ++ state.error === null ? null : (0, import_react40.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error), ++ (0, import_react40.createElement)( ++ SetSection, ++ { key: "new", title: t("New Project") }, ++ (0, import_react40.createElement)( ++ SetRow, ++ { label: t("Name") }, ++ (0, import_react40.createElement)("input", { ++ className: "bt-input", ++ "aria-label": t("Project name"), ++ value: name, ++ placeholder: t("e.g. Website"), ++ onChange: (event) => setName(event.target.value) ++ }) ++ ), ++ (0, import_react40.createElement)( ++ SetRow, ++ { label: t("Working folder"), hint: t("Optional. Where this Project's work happens.") }, ++ (0, import_react40.createElement)(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir }) ++ ), ++ (0, import_react40.createElement)( ++ "div", ++ { className: "bt-actions" }, ++ (0, import_react40.createElement)("button", { ++ type: "button", ++ className: "bt-primary bt-with-icon", ++ disabled: name.trim() === "", ++ onClick: () => { ++ void create(); ++ } ++ }, (0, import_react40.createElement)(PlusIcon), t("New Project")) ++ ) ++ ), ++ // The list is rendered here rather than as a child element: it holds no state of its own, ++ // and the page keeps one reload path for both halves. ++ ...ProjectsList({ projects: state.projects, roster, call: call4, reload }) ++ ]; ++} // src/client/context-menu.js -var import_react29 = require("react"); @@ -16322,7 +16486,7 @@ diff --git a/client.js b/client.js const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10884,13 +17409,22 @@ +@@ -10884,13 +17560,22 @@ const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; @@ -16347,7 +16511,7 @@ diff --git a/client.js b/client.js bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null, room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null, room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null, -@@ -10901,20 +17435,22 @@ +@@ -10901,20 +17586,22 @@ bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -16376,7 +16540,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +17459,14 @@ +@@ -10923,14 +17610,14 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -16395,7 +16559,7 @@ diff --git a/client.js b/client.js const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +17488,14 @@ +@@ -10952,14 +17639,14 @@ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -16415,7 +16579,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10968,9 +17504,23 @@ +@@ -10968,16 +17655,31 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -16423,8 +16587,8 @@ diff --git a/client.js b/client.js + }, (0, import_react41.createElement)(Icon), label)) )) ); -+} -+ + } + +// src/client/pane-width.js +var DEFAULT_WIDTH = 520; +var DETAILS_WIDTH = 380; @@ -16437,10 +16601,10 @@ diff --git a/client.js b/client.js + if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)]; + const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2)); + return [low, Math.max(low, Math.min(DETAILS_MAX, room2))]; - } - ++} ++ // src/client/storage.js -@@ -10978,6 +17528,7 @@ + var STORAGE = { unread: "ds-bot:unread", reactions: "ds-bot:reactions", seen: "ds-bot:seen", @@ -16448,7 +16612,7 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11038,244 +17589,8 @@ +@@ -11038,244 +17740,8 @@ } } @@ -16694,7 +16858,7 @@ diff --git a/client.js b/client.js var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { try { -@@ -11289,10 +17604,10 @@ +@@ -11289,10 +17755,10 @@ var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { @@ -16707,7 +16871,7 @@ diff --git a/client.js b/client.js if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +17617,14 @@ +@@ -11302,14 +17768,14 @@ setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -16726,7 +16890,7 @@ diff --git a/client.js b/client.js key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +17638,42 @@ +@@ -11323,42 +17789,42 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -16784,7 +16948,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +17682,195 @@ +@@ -11367,87 +17833,195 @@ void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -16816,11 +16980,8 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + return (0, import_react42.createElement)( - "div", - null, -- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), -- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), -- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ++ "div", ++ null, + (0, import_react42.createElement)("div", { className: "bt-section-title" }, t(title)), + (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2), + long ? (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null @@ -16873,8 +17034,11 @@ diff --git a/client.js b/client.js + const people2 = facePeople(room2, roster); + const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot }); + return (0, import_react42.createElement)( -+ "div", -+ null, + "div", + null, +- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), +- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), +- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null + (0, import_react42.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), + (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), + (0, import_react42.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react42.createElement)(FacePersonRow, { @@ -17025,7 +17189,7 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11457,7 +17880,7 @@ +@@ -11457,7 +18031,7 @@ const bot = owner; const running = useSessionStatus((map) => map.get(part)?.running === true); const doing = useActivity((map) => map[bot?.id ?? sessionId]); @@ -17034,7 +17198,7 @@ diff --git a/client.js b/client.js const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []])); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -11467,41 +17890,67 @@ +@@ -11467,41 +18041,67 @@ return false; }); const share = () => { @@ -17118,7 +17282,7 @@ diff --git a/client.js b/client.js ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +17964,12 @@ +@@ -11515,12 +18115,12 @@ const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -17135,7 +17299,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,98 +17984,97 @@ +@@ -11535,98 +18135,97 @@ setEditing(false); } } @@ -17279,7 +17443,7 @@ diff --git a/client.js b/client.js var room = { rule: null, selector: "" }; var roomRule = () => { const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected; -@@ -11674,11 +18122,11 @@ +@@ -11674,11 +18273,11 @@ var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab"); function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) { const pages = useBrowserPages((map) => map); @@ -17296,7 +17460,7 @@ diff --git a/client.js b/client.js const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +18155,7 @@ +@@ -11707,7 +18306,7 @@ trail.cancel(); }; }); @@ -17305,7 +17469,7 @@ diff --git a/client.js b/client.js const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +18169,13 @@ +@@ -11721,13 +18320,13 @@ observer.observe(box); return () => observer.disconnect(); }, []); @@ -17322,7 +17486,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -11737,13 +18185,13 @@ +@@ -11737,13 +18336,13 @@ title: bot.name, onClick: () => onView("details") }, @@ -17339,7 +17503,7 @@ diff --git a/client.js b/client.js "button", { key: tabId, -@@ -11764,9 +18212,9 @@ +@@ -11764,9 +18363,9 @@ } } }, @@ -17352,7 +17516,7 @@ diff --git a/client.js b/client.js className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +18226,20 @@ +@@ -11778,20 +18377,20 @@ }, "×") ); }), @@ -17380,7 +17544,7 @@ diff --git a/client.js b/client.js const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,43 +18265,43 @@ +@@ -11817,43 +18416,43 @@ window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -17437,7 +17601,7 @@ diff --git a/client.js b/client.js ref: input, type: "text", spellCheck: false, -@@ -11877,25 +18325,25 @@ +@@ -11877,25 +18476,25 @@ } }) ), @@ -17473,7 +17637,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +18355,41 @@ +@@ -11907,41 +18506,41 @@ const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2); const active = tabState.active; const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE); @@ -17531,7 +17695,7 @@ diff --git a/client.js b/client.js let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +18403,28 @@ +@@ -11955,28 +18554,28 @@ window.removeEventListener("resize", onResize); }; }, []); @@ -17567,7 +17731,7 @@ diff --git a/client.js b/client.js if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); -@@ -12013,7 +18461,7 @@ +@@ -12013,7 +18612,7 @@ return actions[name](...args); }; } @@ -17576,7 +17740,7 @@ diff --git a/client.js b/client.js const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +18544,7 @@ +@@ -12096,7 +18695,7 @@ for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -17585,7 +17749,7 @@ diff --git a/client.js b/client.js "aside", { ref: root, -@@ -12113,15 +18561,15 @@ +@@ -12113,15 +18712,15 @@ if (event.target === event.currentTarget) setLanded(true); } }, @@ -17606,7 +17770,7 @@ diff --git a/client.js b/client.js sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12217,9 +18665,79 @@ +@@ -12217,9 +18816,79 @@ @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -17688,7 +17852,7 @@ diff --git a/client.js b/client.js var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) { -@@ -12227,15 +18745,15 @@ +@@ -12227,15 +18896,15 @@ const ui2 = useUi((value) => value); useRegistry((value) => value); const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); @@ -17708,7 +17872,7 @@ diff --git a/client.js b/client.js if (open) { setKept(open); return void 0; -@@ -12245,19 +18763,26 @@ +@@ -12245,19 +18914,26 @@ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -17745,7 +17909,7 @@ diff --git a/client.js b/client.js ); } -@@ -12294,19 +18819,19 @@ +@@ -12294,19 +18970,19 @@ const auto = String(el.getAttribute("autocomplete") || "").toLowerCase(); return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`); }; @@ -17772,7 +17936,7 @@ diff --git a/client.js b/client.js const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); const childrenOf = (el) => { if (el.shadowRoot) return el.shadowRoot.childNodes; -@@ -12340,8 +18865,8 @@ +@@ -12340,8 +19016,8 @@ items.push(item); return { item, number: items.length, secret }; }; @@ -17783,7 +17947,7 @@ diff --git a/client.js b/client.js ).slice(0, 80); const fenced = (code, lang) => { const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length)); -@@ -12372,7 +18897,7 @@ +@@ -12372,7 +19048,7 @@ const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -17792,7 +17956,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +18923,11 @@ +@@ -12398,11 +19074,11 @@ let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -17806,7 +17970,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12410,18 +18935,18 @@ +@@ -12410,18 +19086,18 @@ for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; @@ -17830,7 +17994,7 @@ diff --git a/client.js b/client.js } width = Math.min(width, limits.tableCols); if (grid.length === 0 || width === 0) return caption ? ` -@@ -12480,9 +19005,9 @@ +@@ -12480,9 +19156,9 @@ if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); @@ -17843,7 +18007,7 @@ diff --git a/client.js b/client.js } if (node.nodeType !== 1) return ""; const el = node; -@@ -12552,7 +19077,7 @@ +@@ -12552,7 +19228,7 @@ } if (tag === "blockquote") { const quoted = block(body2); @@ -17852,7 +18016,7 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -12570,12 +19095,12 @@ +@@ -12570,12 +19246,12 @@ return marked; } const body = document.body; @@ -17869,7 +18033,7 @@ diff --git a/client.js b/client.js stop ||= `the text passed ${limits.chars} characters`; } const page = { -@@ -12585,7 +19110,7 @@ +@@ -12585,7 +19261,7 @@ description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", @@ -17878,7 +18042,7 @@ diff --git a/client.js b/client.js items, stats: { ...stats, ms: Math.round(performance.now() - started) } }; -@@ -12613,15 +19138,15 @@ +@@ -12613,15 +19289,15 @@ var BLOCKED = "Cloud metadata addresses do not open here."; var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname); function browserAddress(input) { @@ -17902,7 +18066,7 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -13052,11 +19577,421 @@ +@@ -13052,11 +19728,430 @@ }; } @@ -18125,7 +18289,7 @@ diff --git a/client.js b/client.js + groups, + count: list.length, + empty: list.length === 0, -+ emptyText: "Nothing under this Project yet.", ++ emptyText: "No cards yet.", + record: projectBoardRecord({ decisions, meetings }) + }; +} @@ -18216,7 +18380,8 @@ diff --git a/client.js b/client.js + (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) + ); +} -+function ProjectBoardOverlay({ actions } = {}) { ++function ProjectBoardOverlay({ actions, call: request } = {}) { ++ const read = request ?? call; + const [state, setState] = (0, import_react46.useState)(null); + const [hash, setHash] = (0, import_react46.useState)(() => typeof window === "undefined" ? "" : window.location.hash); + const close = (0, import_react46.useCallback)(() => { @@ -18240,20 +18405,20 @@ diff --git a/client.js b/client.js + let live = true; + void (async () => { + try { -+ const listing = await call("projects"); ++ const listing = await read("projects"); + const projects = listing?.projects ?? []; + const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); + if (project === void 0) { + if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); + return; + } -+ const [read, board, taskList] = await Promise.all([ -+ call("project-read", { id: project.id }), -+ call("project-board", { id: project.id }), -+ call("tasks", {}) ++ const [record, board, taskList] = await Promise.all([ ++ read("project-read", { id: project.id }), ++ read("project-board", { id: project.id }), ++ read("tasks", {}) + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; -+ if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); ++ if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); + } catch { + if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); + } @@ -18280,7 +18445,15 @@ diff --git a/client.js b/client.js + (0, import_react46.createElement)( + "div", + { className: "bt-project-body" }, -+ state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { ++ state.project === null ? (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-empty" }, ++ (0, import_react46.createElement)("p", null, t("No Project yet — create one.")), ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { ++ close(); ++ actions?.openSettings?.("projects"); ++ } }, t("Open Projects in Settings")) ++ ) : (0, import_react46.createElement)(ProjectBoardPane, { + project: state.project, + rows: state.rows, + canManage: state.canManage, @@ -18325,7 +18498,7 @@ diff --git a/client.js b/client.js function disposeAll(disposers, label) { for (const dispose of [...disposers].reverse()) { try { -@@ -13075,8 +20010,8 @@ +@@ -13075,8 +20170,8 @@ }; return { reconcile(wanted) { @@ -18336,7 +18509,7 @@ diff --git a/client.js b/client.js if (!wanted[group] || mounted[group].length > 0) continue; try { mounted[group] = install[group](); -@@ -13086,7 +20021,7 @@ +@@ -13086,7 +20181,7 @@ } }, dispose() { @@ -18345,7 +18518,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +20040,7 @@ +@@ -13105,7 +20200,7 @@ } // src/client/user-message.js @@ -18354,7 +18527,7 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13119,87 +20054,165 @@ +@@ -13119,87 +20214,165 @@ } return { text: texts2.join("\n"), attachments, rest }; } @@ -18390,8 +18563,7 @@ diff --git a/client.js b/client.js + const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); + const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => withMentionLook({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) }, roster)); + return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)); - } --function userTextParts(text, bots, referenceLabels = []) { ++} +function faceMentionables(room2, roster, fromFriend) { + const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); + const all = faceSeats(room2, roster, { left: true }); @@ -18404,7 +18576,8 @@ diff --git a/client.js b/client.js + return faceAliases(record, seat.bot).map((name) => ({ ...target, name, mentionLabel })); + }); + return bots.concat(humans); -+} + } +-function userTextParts(text, bots, referenceLabels = []) { +function messageAuthor(data, roster, sessionId) { + const source = data?.source ?? {}; + const authorId = source.authorId; @@ -18554,7 +18727,7 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13216,42 +20229,115 @@ +@@ -13216,42 +20389,115 @@ `; // src/client/approval-notices.js @@ -18637,7 +18810,7 @@ diff --git a/client.js b/client.js + timers.clearTimeout?.(timer); + }; } -+ + +// src/client/stats-dock.js +var import_react48 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; @@ -18663,7 +18836,7 @@ diff --git a/client.js b/client.js + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; +} +var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); - ++ // src/client/index.js +var import_react49 = require("react"); var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; @@ -18683,7 +18856,7 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +20360,13 @@ +@@ -13274,7 +20520,13 @@ } catch { seenAt = {}; } @@ -18698,7 +18871,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13308,6 +20400,8 @@ +@@ -13308,6 +20560,8 @@ return () => window.removeEventListener("pagehide", onPageHide); }, "ds-bot: remember visits on page hide"); const activity = createSource({}); @@ -18707,7 +18880,7 @@ diff --git a/client.js b/client.js const work = createSource({ bots: {}, approvals: [] }); let workKey = ""; const live = createSource({}); -@@ -13344,13 +20438,25 @@ +@@ -13344,13 +20598,25 @@ } return { ...state, unread }; }); @@ -18734,7 +18907,7 @@ diff --git a/client.js b/client.js if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); -@@ -13358,6 +20464,11 @@ +@@ -13358,6 +20624,11 @@ activityKey = nextActivity; activity.set(value.activity ?? {}); } @@ -18746,7 +18919,7 @@ diff --git a/client.js b/client.js const nextWork = JSON.stringify(value.work ?? null); if (nextWork !== workKey) { workKey = nextWork; -@@ -13368,10 +20479,15 @@ +@@ -13368,10 +20639,15 @@ const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; @@ -18762,7 +18935,7 @@ diff --git a/client.js b/client.js try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); -@@ -13488,20 +20604,12 @@ +@@ -13488,20 +20764,12 @@ const style = installStyles("", "mentions"); let last = ""; const render = () => { @@ -18785,7 +18958,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +20628,7 @@ +@@ -13520,7 +20788,7 @@ const accent = isHex(prefs.accent) ? prefs.accent : void 0; const motion = prefs.motion === "quiet" || prefs.motion === "lively" ? prefs.motion : "normal"; document.body.dataset.btMotion = motion; @@ -18794,7 +18967,7 @@ diff --git a/client.js b/client.js if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +20644,7 @@ +@@ -13536,7 +20804,7 @@ }; render(); const offRoster = roster.subscribe(render); @@ -18803,7 +18976,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +20696,7 @@ +@@ -13588,6 +20856,7 @@ // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -18811,7 +18984,7 @@ diff --git a/client.js b/client.js if (isAgentSessionId(id)) return actions.openAgentSession(id); ui2.set((state) => state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state); setPlain(false); -@@ -13653,12 +20762,12 @@ +@@ -13653,12 +20922,12 @@ }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -18827,7 +19000,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +20817,15 @@ +@@ -13708,10 +20977,15 @@ }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -18845,7 +19018,7 @@ diff --git a/client.js b/client.js }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); -@@ -13719,6 +20833,19 @@ +@@ -13719,6 +20993,19 @@ closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -18865,7 +19038,7 @@ diff --git a/client.js b/client.js openConnectors: () => { ui2.set((state) => ({ ...state, settings: { page: "connectors", id: null }, menu: null, palette: null, newChat: null })); }, -@@ -13728,7 +20855,7 @@ +@@ -13728,7 +21015,7 @@ if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -18874,7 +19047,7 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); -@@ -13757,6 +20884,12 @@ +@@ -13757,6 +21044,12 @@ const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); }, @@ -18887,7 +19060,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13780,7 +20913,7 @@ +@@ -13780,7 +21073,7 @@ } return { ...state, reactions }; }), @@ -18896,7 +19069,7 @@ diff --git a/client.js b/client.js clearQuote: (token) => ui2.set((state) => state.quote?.token === token ? { ...state, quote: null } : state), clearUnread: (id) => setUnread(id, false), setFlags: (id, flags) => after(call("set-flags", { id, ...flags })), -@@ -13808,10 +20941,16 @@ +@@ -13808,10 +21101,16 @@ setPrefs: (prefs) => after(call("set-prefs", prefs)), setMain: (id, main = true, options = {}) => after(call("set-main", { id, main, ...options })), updateRoom: (id, patch) => after(call("update-room", { id, ...patch })), @@ -18914,7 +19087,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +20958,31 @@ +@@ -13819,15 +21118,31 @@ secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -18947,7 +19120,7 @@ diff --git a/client.js b/client.js scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), -@@ -13836,7 +20991,7 @@ +@@ -13836,7 +21151,7 @@ usage: () => call("usage", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }), // `summarize` starts a summary when the entries changed; `regenerate` always does. memoryView: (botId, flags = {}) => call("memory-view", { botId, ...flags }), @@ -18956,7 +19129,7 @@ diff --git a/client.js b/client.js memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), diagnostics: (payload) => call("diagnostics", payload), diagnosticsLog: () => call("diagnostics-log"), -@@ -13846,6 +21001,8 @@ +@@ -13846,6 +21161,8 @@ noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -18965,7 +19138,7 @@ diff --git a/client.js b/client.js dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +21047,7 @@ +@@ -13890,6 +21207,7 @@ actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -18973,7 +19146,7 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13922,6 +21080,9 @@ +@@ -13922,6 +21240,9 @@ wakeUpdate(); }; actions.repoStars = () => call("repo-stars"); @@ -18983,7 +19156,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; turnClock.set((state) => { -@@ -13964,7 +21125,13 @@ +@@ -13964,7 +21285,13 @@ }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -18998,7 +19171,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +21156,17 @@ +@@ -13989,7 +21316,17 @@ } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -19016,7 +19189,7 @@ diff --git a/client.js b/client.js const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { -@@ -14001,7 +21178,12 @@ +@@ -14001,7 +21338,12 @@ const launched = (sessionId) => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE; const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map((source) => triggers.registerSource(source)); atMenu = { controllerOf }; @@ -19029,7 +19202,7 @@ diff --git a/client.js b/client.js atMenu = null; for (const off of offs) off(); }; -@@ -14010,6 +21192,14 @@ +@@ -14010,6 +21352,14 @@ return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -19044,7 +19217,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +21209,49 @@ +@@ -14019,33 +21369,49 @@ installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -19098,7 +19271,7 @@ diff --git a/client.js b/client.js shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), -@@ -14057,15 +21263,24 @@ +@@ -14057,15 +21423,24 @@ // every message. shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), @@ -19126,7 +19299,7 @@ diff --git a/client.js b/client.js shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice), shadow("conversation.composer", { priority: -10, -@@ -14074,8 +21289,17 @@ +@@ -14074,9 +21449,18 @@ shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), @@ -19134,7 +19307,7 @@ diff --git a/client.js b/client.js + shadow("conversation.input.dock", { id: "bot-secret", order: -90, inject: face }, SecretDock), + shadow("conversation.input.dock", { id: "flybot-session", order: -80, inject: face }, FlyBotSessionDock) ]; -+ ctx.effect(() => { + ctx.effect(() => { + const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); + const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react49.createElement)(TurnFailures, { ...props, work }) }); + return () => { @@ -19142,6 +19315,7 @@ diff --git a/client.js b/client.js + style.remove(); + }; + }, "ds-bot: dead turn lines"); - ctx.effect(() => { ++ ctx.effect(() => { const style = installStyles(RETURN_CSS, "return"); return () => style.remove(); + }, "ds-bot: return styles"); diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 00b630900..b6cdad6c7 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -249,13 +249,14 @@ diff --git a/src/client/secret-card.js b/src/client/secret-card.js diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js -@@ -2,13 +2,16 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're +@@ -2,13 +2,16 @@ import { ACCENT_INKS, INKS } from './inks.js' import { isMainOf } from './sources.js' import { allThemes } from './themes.js' +-import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' +import { WorkflowIcon } from './icons.js' +import { WorkflowsPage } from './workflows.js' - import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' ++import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, FolderIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' +import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' @@ -268,18 +269,26 @@ diff --git a/src/client/settings.js b/src/client/settings.js import { CreateBot } from './create-bot.js' import { KeyIcon, SecretsPage } from './secret-card.js' import { UsagePage } from './usage-page.js' -@@ -23,8 +23,8 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) +@@ -16,6 +16,7 @@ + import { CapabilitiesPage } from './capability-settings.js' + import { SchedulesPage } from './schedules.js' + import { FeedbackIcon, FeedbackPage } from './feedback.js' ++import { FolderMenu } from './folder-menu.js' + import { AboutPage } from './about.js' + import { t } from './i18n.js' + +@@ -23,8 +23,8 @@ // Settings dialog: a 198px page list beside the page. Bots and group chats each list // their members, and one opens on its own settings form. Usage widens the dialog. -const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About DS Bot']] -const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } -+const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] -+const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } ++const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] ++const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } function SetSection({ title, children }) { return h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children)) -@@ -35,6 +35,13 @@ function SetRow({ label, hint, children }) { +@@ -35,6 +35,13 @@ children ? h('div', { className: 'bt-set-control' }, children) : null) } @@ -293,7 +302,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} const themes = allThemes() -@@ -61,9 +61,17 @@ function GeneralPage({ roster, actions, run }) { +@@ -61,9 +61,17 @@ })), h('label', { className: 'bt-swatch bt-swatch-custom', title: t('Custom accent'), 'data-on': Boolean(prefs.accent) && !ACCENT_INKS.some(ink => INKS[ink][0] === prefs.accent) }, h('input', { type: 'color', value: custom, 'aria-label': t('Custom accent'), onChange: event => pickCustom(event.target.value) }))))), @@ -314,7 +323,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js ] } -@@ -72,7 +72,9 @@ function GeneralPage({ roster, actions, run }) { +@@ -72,7 +72,9 @@ function MainBotsSection({ roster, actions, run }) { const mains = (roster.mainBotIds ?? []).map(id => roster.byId[id]).filter(Boolean) const [first, ...others] = mains @@ -325,7 +334,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js return h(SetSection, { title: t('Main Bot') }, h(SetRow, { label: t('Main Bot'), -@@ -81,7 +81,7 @@ function MainBotsSection({ roster, actions, run }) { +@@ -81,7 +81,7 @@ h('select', { className: 'bt-select', 'aria-label': t('Main Bot'), value: first?.id ?? '', onChange: event => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {})), @@ -334,7 +343,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js others.map(bot => h(SetRow, { key: bot.id, label: bot.name, hint: t('Also a Main Bot.') }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t('Move to first')), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, false)) }, t('Remove Main Bot role')))), -@@ -112,8 +112,8 @@ function BotsPage({ roster, actions, run }) { +@@ -112,8 +112,8 @@ ? h(CreateBot, { key: 'create', roster, actions, inline: true, onBack: () => setCreating(false), onCreated: bot => actions.openSettings('bots', bot.id) }) : h('div', { key: 'new', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => setCreating(true) }, h(PlusIcon), t('New Bot'))), @@ -345,7 +354,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js h('span', { className: 'bt-set-bot' }, h(BotAvatar, { bot, size: 28, badge: false, live: false }), h('span', { className: 'bt-row-title' }, bot.name), -@@ -140,7 +140,7 @@ function GroupsPage({ roster, actions, run }) { +@@ -140,7 +140,7 @@ h('span', { className: 'bt-set-bot' }, h(RoomAvatar, { room, roster, size: 28 }), h('span', { className: 'bt-row-title' }, room.name), @@ -354,7 +363,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js admin ? h('span', { className: 'bt-tag bt-tag-admin' }, h(AdminStar), admin.name) : null, room.hidden ? h('span', { className: 'bt-tag' }, t('Hidden')) : null), h('span', { className: 'bt-chevron' }, h(ChevronRightIcon))) -@@ -159,9 +159,13 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -159,9 +159,13 @@ // A Bot or group deleted while its form is open leaves its page on the list. const bot = page === 'bots' && id ? roster.byId[id] : undefined const room = page === 'groups' && id ? roster.roomsById[id] : undefined @@ -370,7 +379,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js const back = bot ? t('Back to Bots') : room ? t('Back to group chats') : usageFocus ? t('All usage') : null const nav = useGlide(shown, { axis: 'y' }) // A page enters from the right when it is one level in, from the left when it comes -@@ -181,16 +181,19 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -181,16 +181,19 @@ const errorLine = error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, error) : null return h('div', { className: 'bt-settings-layer', 'data-leaving': leaving || undefined }, h('div', { className: 'bt-scrim', onMouseDown: close }), @@ -395,15 +404,124 @@ diff --git a/src/client/settings.js b/src/client/settings.js h('div', { className: 'bt-settings-main' }, h('div', { className: 'bt-settings-head' }, back ? h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': back, title: back, onClick: () => actions.openSettings(shown) }, h(ChevronLeftIcon)) : null, -@@ -203,6 +203,8 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -202,7 +202,10 @@ + shown === 'bots' && !bot ? h(BotsPage, { roster, actions, run }) : null, room ? h('div', { className: 'bt-settings-form' }, h(GroupSettingsForm, { key: room.id, room, roster, actions, run })) : null, shown === 'groups' && !room ? h(GroupsPage, { roster, actions, run }) : null, ++ shown === 'projects' ? h(ProjectsPage, { roster, actions, run, call }) : null, shown === 'connectors' ? h(ConnectorsPage, { roster, actions }) : null, + shown === 'workflows' ? h(WorkflowsPage, { call, roster }) : null, + pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null, shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null, shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null, shown === 'secrets' ? h(SecretsPage, { roster, actions, run, Section: SetSection }) : null, +@@ -213,3 +213,105 @@ + h(UsagePage, { roster, actions, focusId: usageFocus })) : null), + h('button', { type: 'button', className: 'bt-settings-close', 'aria-label': t('Close settings'), onClick: close }, h(CloseIcon)))) + } ++ ++// Settings → Projects (#1766): the write half of the Project record. A fresh install has none, so ++// the empty state here is the entry point — create one, then its row opens the board through the ++// hash the board already reads. The working folder comes from the folder picker every other ++// "where does this work happen" control uses; a typed path would be a second way to say it. ++const openProjectBoard = (id) => { ++ if (typeof window !== 'undefined') window.location.hash = `#project=${id}` ++} ++ ++const projectMembers = (roster) => [ ++ ...(roster?.bots ?? []).filter(bot => !bot.remote).map(bot => ({ kind: 'bot', id: bot.id, name: bot.name, projectId: bot.projectId ?? null })), ++ ...(roster?.rooms ?? []).map(room => ({ kind: 'group', id: room.id, name: room.name, projectId: room.projectId ?? null })), ++] ++ ++export function ProjectsList({ projects, roster, call, reload }) { ++ const members = projectMembers(roster) ++ // The binding is the member's own field (`bot.projectId`, `room.projectId`); the record is only ++ // asked again so the row that shows it is read from the host, not guessed here. ++ const bind = async (choice, project) => { ++ const [kind, id] = String(choice ?? '').split(':') ++ if (!kind || !id) return ++ await call('project-bind', { kind, id, projectId: project.id }) ++ await reload?.() ++ } ++ const unbind = async (member) => { ++ await call('project-bind', { kind: member.kind, id: member.id, projectId: null }) ++ await reload?.() ++ } ++ const bound = members.filter(member => member.projectId !== null && member.projectId !== undefined) ++ return [ ++ h(SetSection, { key: 'projects', title: t('Projects · {count}', { count: projects.length }) }, ++ projects.length === 0 ++ ? h('div', { className: 'bt-set-row' }, h('div', { className: 'bt-set-copy' }, h('span', null, t('No Project yet — create one.')))) ++ : projects.map(project => h(SetRow, { ++ key: project.id, label: project.name, ++ hint: project.workDir === null || project.workDir === undefined ? undefined : project.workDir, ++ }, ++ h('div', { className: 'bt-project-row-controls' }, ++ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openProjectBoard(project.id) }, t('Open board')), ++ h('select', { ++ className: 'bt-select', 'aria-label': t('Bind to {name}', { name: project.name }), value: '', ++ onChange: event => { void bind(event.target.value, project) }, ++ }, ++ h('option', { value: '' }, t('Bind a Bot or group')), ++ members.map(member => h('option', { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` }, ++ `${member.kind === 'group' ? t('Group') : t('Bot')} · ${member.name}`))))))), ++ bound.length === 0 ? null : h(SetSection, { key: 'bindings', title: t('Bindings') }, ++ bound.map(member => h(SetRow, { ++ key: `${member.kind}:${member.id}`, label: member.name, ++ hint: member.kind === 'group' ? t('Group') : t('Bot'), ++ }, ++ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))), ++ ] ++} ++ ++export function ProjectsPage({ roster, actions, run, call }) { ++ const [state, setState] = useState({ projects: [], error: null }) ++ const [name, setName] = useState('') ++ const [workDir, setWorkDir] = useState(null) ++ const reload = useCallback(async () => { ++ const listing = await call('projects') ++ setState({ projects: listing?.projects ?? [], error: null }) ++ }, [call]) ++ useEffect(() => { ++ void reload().catch(error => setState({ projects: [], error: error?.message ?? String(error) })) ++ }, [reload]) ++ // Creating re-reads the list rather than appending the answer: the host decides what a Project ++ // is, and the row then shows the record it stored. ++ const create = async () => { ++ const trimmed = name.trim() ++ if (trimmed === '') return ++ const body = { name: trimmed } ++ if (workDir !== null && workDir !== '') body.workDir = workDir ++ try { ++ await call('project-create', body) ++ setName('') ++ setWorkDir(null) ++ await reload() ++ } catch (error) { ++ setState({ projects: state.projects, error: error?.message ?? String(error) }) ++ } ++ } ++ return [ ++ state.error === null ? null : h('div', { key: 'error', className: 'bt-error', role: 'alert' }, state.error), ++ h(SetSection, { key: 'new', title: t('New Project') }, ++ h(SetRow, { label: t('Name') }, ++ h('input', { ++ className: 'bt-input', 'aria-label': t('Project name'), value: name, placeholder: t('e.g. Website'), ++ onChange: event => setName(event.target.value), ++ })), ++ h(SetRow, { label: t('Working folder'), hint: t("Optional. Where this Project's work happens.") }, ++ h(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir })), ++ h('div', { className: 'bt-actions' }, ++ h('button', { ++ type: 'button', className: 'bt-primary bt-with-icon', disabled: name.trim() === '', ++ onClick: () => { void create() }, ++ }, h(PlusIcon), t('New Project')))), ++ // The list is rendered here rather than as a child element: it holds no state of its own, ++ // and the page keeps one reload path for both halves. ++ ...ProjectsList({ projects: state.projects, roster, call, reload }), ++ ] ++} + diff --git a/src/host/browser.js b/src/host/browser.js --- a/src/host/browser.js +++ b/src/host/browser.js diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 5b33db460..e371d15f9 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -159,7 +159,7 @@ export function projectBoardView({ project, rows, canManage, tasks, decisions, m groups, count: list.length, empty: list.length === 0, - emptyText: 'Nothing under this Project yet.', + emptyText: 'No cards yet.', record: projectBoardRecord({ decisions, meetings }), } } @@ -236,7 +236,9 @@ export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, m // The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. // Reading is a POST through the same host channel every other view uses. -export function ProjectBoardOverlay({ actions } = {}) { +export function ProjectBoardOverlay({ actions, call: request } = {}) { + // `call` is a seam for the tests only; in the app it is the channel every other view uses. + const read = request ?? call const [state, setState] = useState(null) // The hash is the whole entry point, so it has to be read again every time it changes: an // entry that writes `#project=` while this mounts opened nothing when the read was one-shot. @@ -259,19 +261,19 @@ export function ProjectBoardOverlay({ actions } = {}) { let live = true void (async () => { try { - const listing = await call('projects') + const listing = await read('projects') const projects = listing?.projects ?? [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) if (project === undefined) { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); return } // Four reads once, none polling: the Project, its record, its rows, and the task texts the // rows are joined against. Nothing here asks a Bot by Bot. - const [read, board, taskList] = await Promise.all([ - call('project-read', { id: project.id }), - call('project-board', { id: project.id }), - call('tasks', {}), + const [record, board, taskList] = await Promise.all([ + read('project-read', { id: project.id }), + read('project-board', { id: project.id }), + read('tasks', {}), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) + if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } @@ -288,7 +290,9 @@ export function ProjectBoardOverlay({ actions } = {}) { h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-project-close', autoFocus: true, onClick: close }, t('Close'))), h('div', { className: 'bt-project-body' }, state.project === null - ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) + ? h('div', { className: 'bt-project-empty' }, + h('p', null, t('No Project yet — create one.')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { close(); actions?.openSettings?.('projects') } }, t('Open Projects in Settings'))) // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads // that hash and lands on the Bot or group the task belongs to. : h(ProjectBoardPane, { diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js index c05847f36..3ee6fd7b8 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -12,23 +12,40 @@ const bundle = await build({ bundle: true, format: 'cjs', platform: 'node', write: false, external: ['react', 'react-dom'], }) +// A hook runtime: the overlay is a plain function, so a slot array that survives a re-render is +// enough to mount it, let its one read land, and read the tree it draws. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {} } const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, console, + module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, console, window: win, require: (name) => { - const platform = { - react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), - useCallback: fn => fn, - useEffect: () => {}, - useState: initial => [initial, () => {}], - }, - } - if (!(name in platform)) throw new Error(`no platform module ${name}`) - return platform[name] + if (name === 'react') return react + throw new Error(`no platform module ${name}`) }, }) -const { ProjectBoardPane, projectBoardView } = module.exports +const { ProjectBoardOverlay, ProjectBoardPane, projectBoardView } = module.exports +const render = (component, props) => { index = 0; return component(props) } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + await new Promise(resolve => setTimeout(resolve, 0)) +} const rows = [ { id: 'task-b1', source: 'bot', column: 'in-progress', assignee: 'Dev4', updatedAt: 3 }, @@ -103,7 +120,24 @@ test('the pane draws the grouped rows and only the owner sees the management con test('an empty Project renders the empty text and no row', () => { const nodes = all(ProjectBoardPane({ project: { id: 'p', name: 'P' }, rows: [], canManage: false })) assert.equal(nodes.filter(node => node.props?.['data-task-id'] !== undefined).length, 0) - assert.match(nodes.map(textOf).join(' '), /Nothing under this Project yet/) + assert.match(nodes.map(textOf).join(' '), /No cards yet/) +}) + +test('a Project-less entry offers the page that creates one (#1766)', async () => { + slots = [] + effects = [] + win.location.hash = '#project' + const opened = [] + const call = async () => ({ projects: [] }) + let tree = render(ProjectBoardOverlay, { actions: { openSettings: key => opened.push(key) }, call }) + await flush() + tree = render(ProjectBoardOverlay, { actions: { openSettings: key => opened.push(key) }, call }) + const nodes = all(tree) + assert.match(nodes.map(textOf).join(' '), /No Project yet — create one/) + const button = nodes.find(node => node.type === 'button' && /Open Projects in Settings/.test(textOf(node))) + assert.ok(button, 'the empty state names the way out') + button.props.onClick() + assert.deepEqual(opened, ['projects']) }) // #1767: the record beside the cards. The view passes through what the host read — a decision diff --git a/patches/ds-bot/files/test/project-settings-page.test.js b/patches/ds-bot/files/test/project-settings-page.test.js new file mode 100644 index 000000000..a9197877a --- /dev/null +++ b/patches/ds-bot/files/test/project-settings-page.test.js @@ -0,0 +1,161 @@ +// Settings → Projects (#1766): a fresh install has no Project, so the page *is* the way to make +// one. The page calls `project-create` and then reads `projects` again; a binding is the member's +// own field, so binding and unbinding both go through `project-bind`; and the working folder comes +// from the folder picker every other "where does this happen" control uses, not a typed path. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { SettingsDialog, ProjectsPage, ProjectsList } from './src/client/settings.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +// A small hook runtime: the pages are plain functions, so a slot array that survives a re-render is +// enough to type a name and press the button the way a person would. +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SettingsDialog, ProjectsPage, ProjectsList } = module.exports + +const render = (component, props) => { index = 0; return component(props) } +const flush = async () => { + const queued = effects + effects = [] + for (const fn of queued) await fn() + await new Promise(resolve => setTimeout(resolve, 0)) +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string') return node + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} +const roster = { + bots: [{ id: 'a', name: 'Chief' }, { id: 'b', name: 'Writer' }], + rooms: [{ id: 'r1', name: 'Ops', projectId: 'project-1' }], + byId: {}, roomsById: {}, prefs: {}, mainBotIds: [], connectors: [], +} +const actions = { openSettings() {}, closeSettings() {} } + +test('Settings lists a Projects page beside Bots and group chats, and draws it', () => { + const tree = SettingsDialog({ page: 'projects', id: null, roster, actions, useUpdate: () => ({}) }) + const nodes = all(tree) + const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) + assert.deepEqual(items.slice(0, 4), ['General', 'Bots', 'Group chats', 'Projects']) + assert.equal(text(nodes.find(node => node.type === 'h2')), 'Projects') + const page = nodes.find(node => typeof node.type === 'function' && typeof node.props?.call === 'function' && node.props?.roster === roster) + assert.ok(page, 'the Projects page is the one rendered for the page key') +}) + +test('an install with no Project says so, and creating one re-reads the list', async () => { + const calls = [] + const call = async (endpoint, body) => { + // The body is built inside the bundle, so it is copied across the realm before comparing. + calls.push(body === undefined ? [endpoint] : [endpoint, JSON.parse(JSON.stringify(body))]) + if (endpoint === 'projects') return { projects: [] } + if (endpoint === 'project-create') return { project: { id: 'project-1', name: body.name, workDir: body.workDir ?? null } } + return {} + } + slots = [] + effects = [] + let page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + await flush() + page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + let nodes = all(page) + assert.match(nodes.map(text).join(' '), /No Project yet — create one/) + + // The folder is chosen, not typed: the one text input is the name. + const inputs = nodes.filter(node => node.type === 'input') + assert.equal(inputs.length, 1) + assert.equal(inputs[0].props['aria-label'], 'Project name') + const folderRow = nodes.find(node => node.props?.label === 'Working folder') + assert.ok(folderRow, 'the create form has a working folder row') + const picker = all(folderRow).find(node => typeof node.type === 'function' && typeof node.props?.onPick === 'function') + assert.ok(picker, 'the working folder comes from the folder picker') + + inputs[0].props.onChange({ target: { value: 'Website' } }) + page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + nodes = all(page) + picker.props.onPick('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/work/site') + page = render(ProjectsPage, { roster, actions, run: fn => fn, call }) + nodes = all(page) + const create = nodes.find(node => node.type === 'button' && /New Project/.test(text(node))) + assert.ok(create) + create.props.onClick() + await flush() + + assert.deepEqual(calls, [ + ['projects'], + ['project-create', { name: 'Website', workDir: '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/work/site' }], + ['projects'], + ], 'a new Project is created and the list is read again') +}) + +test('a row opens its board, a Bot binds with its Project id, and unbinding sends null', async () => { + const calls = [] + const call = async (endpoint, body) => { calls.push([endpoint, JSON.parse(JSON.stringify(body))]) } + const reload = async () => { calls.push(['projects']) } + const project = { id: 'project-1', name: 'Site', workDir: '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/work/site' } + const nodes = all(ProjectsList({ projects: [project], roster, call, reload })) + assert.ok(nodes.some(node => node.props?.label === 'Site'), 'the Project is listed by name') + + const open = nodes.find(node => node.type === 'button' && text(node) === 'Open board') + assert.ok(open, 'a row opens the board') + win.location.hash = '' + open.props.onClick() + assert.equal(win.location.hash, '#project=project-1', 'the row opens the hash the board already reads') + + const select = nodes.find(node => node.type === 'select') + assert.ok(select, 'a Bot or group can be bound from the row') + select.props.onChange({ target: { value: 'bot:a' } }) + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(calls[0], ['project-bind', { kind: 'bot', id: 'a', projectId: 'project-1' }]) + assert.deepEqual(calls[1], ['projects'], 'the binding is read back, not assumed') + + const unbind = nodes.find(node => node.type === 'button' && text(node) === 'Unbind') + assert.ok(unbind, 'a bound group is listed with an unbind control') + unbind.props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(calls[2], ['project-bind', { kind: 'group', id: 'r1', projectId: null }]) +}) diff --git a/patches/ds-bot/files/test/projects.test.js b/patches/ds-bot/files/test/projects.test.js index fd3457c9f..2a78a8ccc 100644 --- a/patches/ds-bot/files/test/projects.test.js +++ b/patches/ds-bot/files/test/projects.test.js @@ -269,3 +269,24 @@ test('a signed-in member may read the Project board even though it may not manag assert.deepEqual(value(board).tasks, []) } finally { await stop() } }) + +// #1766: the Settings page needs somewhere to say where a Project works, so the record carries an +// optional folder — the absolute path the folder picker hands back, never a relative one. +test('a Project carries an optional working folder and refuses a relative one', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Site', workDir: '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/work/site' })) + assert.equal(project.workDir, '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/work/site') + assert.equal(created(await call('project-read', { id: project.id })).workDir, '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/work/site') + assert.equal(value(await call('projects', {})).projects[0].workDir, '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/work/site', 'the list carries it too') + + assert.equal(created(await call('project-create', { name: 'Bare' })).workDir, null) + // An empty value is the field left alone, not a relative folder. + assert.equal(created(await call('project-create', { name: 'Blank', workDir: '' })).workDir, null); + for (const workDir of ['site', '../site', 'not-absolute/site']) { + const refused = await call('project-create', { name: 'Bad', workDir }) + assert.equal(refused.ok, false, `${JSON.stringify(workDir)} is not a folder`) + assert.match(refused.error.message, /absolute path/) + } + } finally { await stop() } +}) diff --git a/patches/ds-bot/files/test/settings-page-slot.test.js b/patches/ds-bot/files/test/settings-page-slot.test.js index 36c617700..73fff6f8b 100644 --- a/patches/ds-bot/files/test/settings-page-slot.test.js +++ b/patches/ds-bot/files/test/settings-page-slot.test.js @@ -39,7 +39,7 @@ test('a flybot.settings.page entry is listed after Connectors and drawn with the const tree = SettingsDialog({ page: 'plugin:social', id: null, roster, actions, useUpdate: () => ({}) }) const nodes = all(tree) const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) - assert.deepEqual(items.slice(3, 6), ['Connectors', 'Bot Social', 'Capabilities']) + assert.deepEqual(items.slice(4, 7), ['Connectors', 'Bot Social', 'Capabilities']) assert.equal(text(nodes.find(node => node.type === 'h2')), 'Bot Social') const slot = nodes.find(node => node.props?.entry?.id === 'social') assert.ok(slot) @@ -56,6 +56,6 @@ test('a page whose plugin is gone falls back to General, and without plugins the const nodes = all(tree) assert.equal(text(nodes.find(node => node.type === 'h2')), 'General') const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text) - assert.deepEqual(items.slice(0, 6), ['General', 'Bots', 'Group chats', 'Connectors', 'Capabilities', 'Scheduled tasks']) + assert.deepEqual(items.slice(0, 7), ['General', 'Bots', 'Group chats', 'Projects', 'Connectors', 'Capabilities', 'Scheduled tasks']) assert.equal(nodes.some(node => node.props?.entry !== undefined), false) })