diff --git a/changelog.d/1749-project-board-settings-members.fixed.md b/changelog.d/1749-project-board-settings-members.fixed.md new file mode 100644 index 000000000..d12136d48 --- /dev/null +++ b/changelog.d/1749-project-board-settings-members.fixed.md @@ -0,0 +1 @@ +Project board: the **Project settings** and **Members** buttons now open panels ([#1749](https://github.com/ThinkFlowLab/FlyBot/issues/1749)). Settings saves bindings, the Project Assistant's digest setting and its memory through the existing host write paths; the Members panel lists the Project's owners and the members its cards name, read-only for now. diff --git a/core/client.js b/core/client.js index 7ee8115ab..975732f95 100644 --- a/core/client.js +++ b/core/client.js @@ -1382,6 +1382,24 @@ var ZH = { "Open board": "打开看板", "Unbind": "解除绑定", "Bindings": "绑定关系", + // The Project settings and Members panels (#1749). + "Bind a member to this Project": "把成员绑定到这个项目", + "Member kind": "成员类型", + "Member id": "成员 ID", + "Member bound.": "成员已绑定。", + "Bind": "绑定", + "Member unbound.": "已解除成员的绑定。", + "Project Assistant": "项目助手", + "Pause the daily digest": "暂停每日摘要", + "Assistant settings saved.": "助手设置已保存。", + "Add to the assistant memory": "添加到助手记忆", + "Memory saved.": "记忆已保存。", + "Save memory": "保存记忆", + "Back to board": "返回看板", + "Owner": "负责人", + "On the board": "在看板上", + "No member is named yet.": "还没有指定成员。", + "Member management is not part of this slice; owners and the members their cards name are listed here.": "成员管理不在本次范围内;这里列出项目负责人和卡片上点名的成员。", "No cards yet.": "还没有卡片。", "Open Projects in Settings": "在设置中打开项目" }; @@ -19884,6 +19902,21 @@ var PROJECT_BOARD_CSS = ` .bt-project-state{font-size:11px;margin-left:auto} .bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} .bt-project-reason{font-size:11px;opacity:.7} + +/* #1749: management panels. Same surface as the board, one form row per control, and a status + line that quotes the host's own answer — a refused save reads where the reader pressed it. */ +.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} +.bt-project-panel h3{margin:0} +.bt-project-field{display:flex;flex-direction:column;gap:4px} +.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} +.bt-project-field textarea{min-height:88px;resize:vertical} +.bt-project-row-actions{display:flex;gap:8px;align-items:center} +.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} +.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} +.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} +.bt-project-note{font-size:12px;opacity:.65;margin:0} `; var PROJECT_SOURCES = ["bot", "group", "workflow"]; var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; @@ -20119,7 +20152,7 @@ function ProjectBoardRecord({ record }) { meetings.pending.length === 0 ? null : pendingRows ); } -function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { +function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen, onSettings, onMembers }) { const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }); return (0, import_react46.createElement)( "div", @@ -20134,21 +20167,153 @@ function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings (0, import_react46.createElement)("span", null, [t("Blocked"), ": ", (0, import_react46.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), (0, import_react46.createElement)("span", null, [t("Done"), ": ", (0, import_react46.createElement)("b", { key: "d" }, countText(view.counts.done))]) ), - // Management affordances are drawn only for the owner; the refusal itself is the host's. - view.canManage ? (0, import_react46.createElement)("div", { className: "bt-project-actions" }, (0, import_react46.createElement)("button", { type: "button" }, t("Project settings")), (0, import_react46.createElement)("button", { type: "button" }, t("Members"))) : null, + // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses + // every write a non-owner attempts, so the refusal is an answer the reader sees, not a + // control that silently disappeared. + (0, import_react46.createElement)( + "div", + { className: "bt-project-actions" }, + (0, import_react46.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), + (0, import_react46.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) + ), (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }), (0, import_react46.createElement)(ProjectQueueGroups, { view, onOpen }), (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) ); } +function ProjectSettingsPanel({ project, call: request, onClose, onSaved } = {}) { + const save = request ?? call; + const [kind, setKind] = (0, import_react46.useState)("bot"); + const [memberId, setMemberId] = (0, import_react46.useState)(""); + const [note, setNote] = (0, import_react46.useState)(""); + const [assistantOff, setAssistantOff] = (0, import_react46.useState)(project?.assistant?.off === true); + const [status, setStatus] = (0, import_react46.useState)(""); + const id = text(project?.id); + const run = async (endpoint, payload, done) => { + try { + const result = await save(endpoint, payload); + setStatus(done); + onSaved?.(result); + return result; + } catch (error) { + setStatus(error?.message ?? String(error)); + return null; + } + }; + return (0, import_react46.createElement)( + "div", + { className: "bt-project-panel", "data-panel": "project-settings" }, + (0, import_react46.createElement)("h3", null, t("Project settings")), + (0, import_react46.createElement)( + "div", + { className: "bt-project-field" }, + (0, import_react46.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), + (0, import_react46.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react46.createElement)( + "select", + { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) }, + (0, import_react46.createElement)("option", { value: "bot" }, t("Bot")), + (0, import_react46.createElement)("option", { value: "group" }, t("Group")) + ), + (0, import_react46.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }), + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) + ) + ), + (0, import_react46.createElement)( + "div", + { className: "bt-project-field" }, + (0, import_react46.createElement)("label", null, t("Project Assistant")), + (0, import_react46.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react46.createElement)("label", null, (0, import_react46.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) + ) + ), + (0, import_react46.createElement)( + "div", + { className: "bt-project-field" }, + (0, import_react46.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")), + (0, import_react46.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }), + (0, import_react46.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-memory-save", onClick: () => run("project-memory-write", { id, kind: "note", text: text(note) }, t("Memory saved.")) }, t("Save memory")) + ) + ), + (0, import_react46.createElement)("div", { className: "bt-project-status-line", role: "status" }, status), + (0, import_react46.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) + ); +} +function ProjectMembersPanel({ project, rows, onClose } = {}) { + const owners = []; + const add = (id) => { + const clean = text(id); + if (clean !== "" && !owners.includes(clean)) owners.push(clean); + }; + add(project?.ownerUserId); + for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id); + const boardMembers = []; + for (const row of Array.isArray(rows) ? rows : []) { + const name = text(row?.assignee); + if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name); + } + return (0, import_react46.createElement)( + "div", + { className: "bt-project-panel", "data-panel": "project-members" }, + (0, import_react46.createElement)("h3", null, t("Members")), + (0, import_react46.createElement)( + "ul", + { className: "bt-project-members" }, + owners.map((owner) => (0, import_react46.createElement)( + "li", + { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner }, + (0, import_react46.createElement)("span", null, `@${owner}`), + (0, import_react46.createElement)("span", { className: "bt-project-member-kind" }, t("Owner")) + )), + boardMembers.map((name) => (0, import_react46.createElement)( + "li", + { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name }, + (0, import_react46.createElement)("span", null, `@${name}`), + (0, import_react46.createElement)("span", { className: "bt-project-member-kind" }, t("On the board")) + )) + ), + owners.length === 0 && boardMembers.length === 0 ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null, + (0, import_react46.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), + (0, import_react46.createElement)( + "div", + { className: "bt-project-row-actions" }, + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) + ); +} function ProjectBoardOverlay({ actions, call: request } = {}) { const read = request ?? call; const [state, setState] = (0, import_react46.useState)(null); + const [panel, setPanel] = (0, import_react46.useState)(null); const [hash, setHash] = (0, import_react46.useState)(() => typeof window === "undefined" ? "" : window.location.hash); const close = (0, import_react46.useCallback)(() => { if (typeof window !== "undefined") window.location.hash = ""; + setPanel(null); setState(null); }, []); + (0, import_react46.useEffect)(() => { + if (typeof window === "undefined") return void 0; + const onKey = (event) => { + if (event.key !== "Escape") return; + if (panel !== null) setPanel(null); + else close(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [panel, close]); (0, import_react46.useEffect)(() => { if (typeof window === "undefined") return void 0; const sync = () => setHash(window.location.hash); @@ -20215,7 +20380,7 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { close(); actions?.openSettings?.("projects"); } }, t("Open Projects in Settings")) - ) : (0, import_react46.createElement)(ProjectBoardPane, { + ) : panel === "settings" ? (0, import_react46.createElement)(ProjectSettingsPanel, { project: state.project, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react46.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react46.createElement)(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, @@ -20223,6 +20388,8 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { decisions: state.decisions, meetings: state.meetings, queues: state.queues, + onSettings: () => setPanel("settings"), + onMembers: () => setPanel("members"), // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and // lands on that Bot or group. Nothing here re-decides which board that is. onOpen: (board) => { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 7c5144071..fc166be96 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1335,6 +1335,24 @@ const ZH = { 'Open board': '打开看板', 'Unbind': '解除绑定', 'Bindings': '绑定关系', + // The Project settings and Members panels (#1749). + 'Bind a member to this Project': '把成员绑定到这个项目', + 'Member kind': '成员类型', + 'Member id': '成员 ID', + 'Member bound.': '成员已绑定。', + 'Bind': '绑定', + 'Member unbound.': '已解除成员的绑定。', + 'Project Assistant': '项目助手', + 'Pause the daily digest': '暂停每日摘要', + 'Assistant settings saved.': '助手设置已保存。', + 'Add to the assistant memory': '添加到助手记忆', + 'Memory saved.': '记忆已保存。', + 'Save memory': '保存记忆', + 'Back to board': '返回看板', + 'Owner': '负责人', + 'On the board': '在看板上', + 'No member is named yet.': '还没有指定成员。', + 'Member management is not part of this slice; owners and the members their cards name are listed here.': '成员管理不在本次范围内;这里列出项目负责人和卡片上点名的成员。', 'No cards yet.': '还没有卡片。', 'Open Projects in Settings': '在设置中打开项目', } diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 7f31a6f6a..936d0a411 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -42,6 +42,21 @@ export const PROJECT_BOARD_CSS = ` .bt-project-state{font-size:11px;margin-left:auto} .bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} .bt-project-reason{font-size:11px;opacity:.7} + +/* #1749: management panels. Same surface as the board, one form row per control, and a status + line that quotes the host's own answer — a refused save reads where the reader pressed it. */ +.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} +.bt-project-panel h3{margin:0} +.bt-project-field{display:flex;flex-direction:column;gap:4px} +.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} +.bt-project-field textarea{min-height:88px;resize:vertical} +.bt-project-row-actions{display:flex;gap:8px;align-items:center} +.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} +.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} +.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} +.bt-project-note{font-size:12px;opacity:.65;margin:0} ` // Sources in the order the board reads them; a source with no rows is left out so three empty @@ -300,7 +315,7 @@ function ProjectBoardRecord({ record }) { meetings.pending.length === 0 ? null : pendingRows) } -export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { +export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen, onSettings, onMembers }) { const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), @@ -310,26 +325,121 @@ export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, m h('span', null, [t('In flight'), ': ', h('b', { key: 'f' }, countText(view.counts.inflight))]), h('span', null, [t('Blocked'), ': ', h('b', { key: 'b' }, countText(view.counts.blocked))]), h('span', null, [t('Done'), ': ', h('b', { key: 'd' }, countText(view.counts.done))])), - // Management affordances are drawn only for the owner; the refusal itself is the host's. - view.canManage ? h('div', { className: 'bt-project-actions' }, h('button', { type: 'button' }, t('Project settings')), h('button', { type: 'button' }, t('Members'))) : null, + // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses + // every write a non-owner attempts, so the refusal is an answer the reader sees, not a + // control that silently disappeared. + h('div', { className: 'bt-project-actions' }, + h('button', { type: 'button', 'data-action': 'project-settings', onClick: () => onSettings?.() }, t('Project settings')), + h('button', { type: 'button', 'data-action': 'project-members', onClick: () => onMembers?.() }, t('Members'))), h(ProjectBoardRows, { view, onOpen }), h(ProjectQueueGroups, { view, onOpen }), h(ProjectBoardRecord, { record: view.record })) } +// #1749: the settings panel. It writes through the three paths the host already had — bind or +// unbind a member (`project-bind`), the assistant's per-owner controls +// (`project-assistant-owner`) and the assistant's memory (`project-memory-write`) — and adds no +// fourth. Fields the host has no write path for are not drawn, so every control here can save. +export function ProjectSettingsPanel({ project, call: request, onClose, onSaved } = {}) { + const save = request ?? call + const [kind, setKind] = useState('bot') + const [memberId, setMemberId] = useState('') + const [note, setNote] = useState('') + const [assistantOff, setAssistantOff] = useState(project?.assistant?.off === true) + const [status, setStatus] = useState('') + const id = text(project?.id) + const run = async (endpoint, payload, done) => { + try { + const result = await save(endpoint, payload) + setStatus(done) + onSaved?.(result) + return result + } catch (error) { + // The host's refusal is the answer the reader acted for; quote it instead of guessing. + setStatus(error?.message ?? String(error)) + return null + } + } + return h('div', { className: 'bt-project-panel', 'data-panel': 'project-settings' }, + h('h3', null, t('Project settings')), + h('div', { className: 'bt-project-field' }, + h('label', { htmlFor: 'bt-project-member-id' }, t('Bind a member to this Project')), + h('div', { className: 'bt-project-row-actions' }, + h('select', { 'aria-label': t('Member kind'), value: kind, onChange: event => setKind(event.target.value) }, + h('option', { value: 'bot' }, t('Bot')), + h('option', { value: 'group' }, t('Group'))), + h('input', { id: 'bt-project-member-id', value: memberId, placeholder: t('Member id'), onChange: event => setMemberId(event.target.value) }), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-bind', onClick: () => run('project-bind', { kind, id: text(memberId), projectId: id }, t('Member bound.')) }, t('Bind')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-unbind', onClick: () => run('project-bind', { kind, id: text(memberId), projectId: null }, t('Member unbound.')) }, t('Unbind')))), + h('div', { className: 'bt-project-field' }, + h('label', null, t('Project Assistant')), + h('div', { className: 'bt-project-row-actions' }, + h('label', null, h('input', { type: 'checkbox', 'data-action': 'assistant-off', checked: assistantOff, onChange: event => setAssistantOff(event.target.checked) }), ' ', t('Pause the daily digest')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-assistant-save', onClick: () => run('project-assistant-owner', { id, settings: { off: assistantOff === true } }, t('Assistant settings saved.')) }, t('Save')))), + h('div', { className: 'bt-project-field' }, + h('label', { htmlFor: 'bt-project-note' }, t('Add to the assistant memory')), + h('textarea', { id: 'bt-project-note', value: note, onChange: event => setNote(event.target.value) }), + h('div', { className: 'bt-project-row-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-memory-save', onClick: () => run('project-memory-write', { id, kind: 'note', text: text(note) }, t('Memory saved.')) }, t('Save memory')))), + h('div', { className: 'bt-project-status-line', role: 'status' }, status), + h('div', { className: 'bt-project-row-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board')))) +} + +// #1749: who is on this Project, read from the record and the board — never a second member read. +// The host keeps no membership set beyond a Project's owners and the members its board rows +// already name, so this panel is read-only and says so instead of offering a control it cannot save. +export function ProjectMembersPanel({ project, rows, onClose } = {}) { + const owners = [] + const add = (id) => { const clean = text(id); if (clean !== '' && !owners.includes(clean)) owners.push(clean) } + add(project?.ownerUserId) + for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id) + const boardMembers = [] + for (const row of Array.isArray(rows) ? rows : []) { + const name = text(row?.assignee) + if (name !== '' && !boardMembers.includes(name)) boardMembers.push(name) + } + return h('div', { className: 'bt-project-panel', 'data-panel': 'project-members' }, + h('h3', null, t('Members')), + h('ul', { className: 'bt-project-members' }, + owners.map(owner => h('li', { key: `owner:${owner}`, className: 'bt-project-member', 'data-member-role': 'owner', 'data-member-id': owner }, + h('span', null, `@${owner}`), + h('span', { className: 'bt-project-member-kind' }, t('Owner')))), + boardMembers.map(name => h('li', { key: `board:${name}`, className: 'bt-project-member', 'data-member-role': 'board', 'data-member-id': name }, + h('span', null, `@${name}`), + h('span', { className: 'bt-project-member-kind' }, t('On the board'))))), + owners.length === 0 && boardMembers.length === 0 ? h('div', { className: 'bt-project-empty' }, t('No member is named yet.')) : null, + h('p', { className: 'bt-project-note' }, t('Member management is not part of this slice; owners and the members their cards name are listed here.')), + h('div', { className: 'bt-project-row-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board')))) +} + // 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, 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) + const [panel, setPanel] = 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. const [hash, setHash] = useState(() => (typeof window === 'undefined' ? '' : window.location.hash)) const close = useCallback(() => { if (typeof window !== 'undefined') window.location.hash = '' + setPanel(null) setState(null) }, []) + // Escape leaves the panel first, then the board — the same two steps the kanban dialog offers. + useEffect(() => { + if (typeof window === 'undefined') return undefined + const onKey = (event) => { + if (event.key !== 'Escape') return + if (panel !== null) setPanel(null) + else close() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [panel, close]) useEffect(() => { if (typeof window === 'undefined') return undefined const sync = () => setHash(window.location.hash) @@ -380,9 +490,13 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { 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. + : panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, call: read, onClose: () => setPanel(null) }) + : panel === 'members' ? h(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, decisions: state.decisions, meetings: state.meetings, queues: state.queues, + onSettings: () => setPanel('settings'), + onMembers: () => setPanel('members'), // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and // lands on that Bot or group. Nothing here re-decides which board that is. onOpen: (board) => { diff --git a/core/test/project-board-panels.test.js b/core/test/project-board-panels.test.js new file mode 100644 index 000000000..12bce8246 --- /dev/null +++ b/core/test/project-board-panels.test.js @@ -0,0 +1,123 @@ +// Project board's two management buttons (#1749). The board drew "Project settings" and "Members" +// with no handler at all, so clicking either only moved focus. These pin the three surfaces a +// reader now gets: each button opens its panel, the settings panel saves only through the write +// paths the host already has, and the members panel lists who the record and the board name. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/project-board.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], +}) +// A hook runtime: every panel is a plain function, so a slot array that survives a re-render is +// enough to type into a field, press a button, and read the tree it draws next. +let slots = [] +let index = 0 +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: () => { const at = index++; if (!(at in slots)) slots[at] = true }, + 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 module = { exports: {} } +runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, Date, Promise, console, + window: { location: { hash: '' }, addEventListener() {}, removeEventListener() {} }, + require: (name) => { + if (name === 'react') return react + throw new Error(`no platform module ${name}`) + }, +}) +const { ProjectBoardPane, ProjectSettingsPanel, ProjectMembersPanel } = module.exports + +const render = (component, props) => { index = 0; return component(props) } +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node === null || typeof node !== 'object') return out + if (typeof node.type === 'function') return all(node.type(node.props), out) + out.push(node) + all(node.children, out) + return out +} +const byAction = (tree, action) => all(tree).find(node => node.props?.['data-action'] === action) +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.children) + return '' +} +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +const rows = [{ id: 'task-1', source: 'bot', column: 'in-progress', assignee: 'Dev4' }] + +test('both management buttons open their panel, owner or not', () => { + const opened = [] + const tree = render(ProjectBoardPane, { + project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, + onSettings: () => opened.push('settings'), onMembers: () => opened.push('members'), + }) + const settings = byAction(tree, 'project-settings') + const members = byAction(tree, 'project-members') + assert.ok(settings, 'Project settings is drawn for a non-owner too — the host refuses, not the page') + assert.ok(members, 'Members is drawn for a non-owner too') + settings.props.onClick() + members.props.onClick() + assert.deepEqual(opened, ['settings', 'members']) +}) + +test('the settings panel saves through project-bind, project-assistant-owner and project-memory-write', async () => { + const sent = [] + const save = async (endpoint, payload) => { sent.push({ endpoint, payload }); return { ok: true } } + let tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + const memberInput = all(tree).find(node => node.props?.id === 'bt-project-member-id') + memberInput.props.onChange({ target: { value: 'bot-9' } }) + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + byAction(tree, 'project-bind').props.onClick() + await tick() + assert.deepEqual(JSON.parse(JSON.stringify(sent.at(-1))), { endpoint: 'project-bind', payload: { kind: 'bot', id: 'bot-9', projectId: 'project-1' } }) + + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + byAction(tree, 'project-assistant-save').props.onClick() + await tick() + assert.deepEqual(JSON.parse(JSON.stringify(sent.at(-1))), { endpoint: 'project-assistant-owner', payload: { id: 'project-1', settings: { off: false } } }) + + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + const note = all(tree).find(node => node.props?.id === 'bt-project-note') + note.props.onChange({ target: { value: 'ship on Friday' } }) + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await tick() + assert.deepEqual(JSON.parse(JSON.stringify(sent.at(-1))), { endpoint: 'project-memory-write', payload: { id: 'project-1', kind: 'note', text: 'ship on Friday' } }) +}) + +test("the settings panel shows the host's refusal instead of pretending the save landed", async () => { + const refused = Object.assign(new Error('Only the owner of this Project may change it'), { code: 'bot/forbidden' }) + const save = async () => { throw refused } + let tree = render(ProjectSettingsPanel, { project: { id: 'project-1' }, call: save, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await tick() + tree = render(ProjectSettingsPanel, { project: { id: 'project-1' }, call: save, onClose() {} }) + const status = all(tree).find(node => node.props?.role === 'status') + assert.match(text(status), /Only the owner of this Project/) +}) + +test('the members panel lists the owners and the members the board names, and offers no write', () => { + const tree = render(ProjectMembersPanel, { + project: { id: 'project-1', ownerUserId: 'owner-1', ownerUserIds: ['owner-1', 'owner-2'] }, + rows, onClose() {}, + }) + const members = all(tree).filter(node => node.props?.['data-member-role'] !== undefined) + assert.deepEqual(members.map(node => node.props['data-member-id']), ['owner-1', 'owner-2', 'Dev4']) + assert.deepEqual(members.map(node => node.props['data-member-role']), ['owner', 'owner', 'board']) + assert.equal(byAction(tree, 'project-bind'), undefined, 'membership management is a later slice, not a dead button here') +}) diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index c5b38709d..f6d54d165 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -103,7 +103,7 @@ test('a Project with nothing under it is an empty state, not an error', () => { assert.equal(missing.canManage, false, 'no canManage answer is read as read-only') }) -test('the pane draws the grouped rows and only the owner sees the management controls', () => { +test('the pane draws the grouped rows and both management buttons, owner or not (#1749)', () => { const owner = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: true })) const member = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: false })) assert.match(textOf(owner[0]), /FlyBot/) @@ -114,7 +114,8 @@ test('the pane draws the grouped rows and only the owner sees the management con assert.equal(owner.some(node => textOf(node) === 'Project settings'), true) const memberRows = member.filter(node => node.props?.['data-task-id'] !== undefined) assert.deepEqual(memberRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1'], 'a member still reads the board') - assert.equal(member.some(node => textOf(node) === 'Project settings'), false) + // #1749: a non-owner sees the same two buttons; the host refuses the write and the panel says so. + assert.equal(member.some(node => textOf(node) === 'Project settings'), true) }) test('an empty Project renders the empty text and no row', () => { diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index b93a5768c..13f87ae96 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1417,7 +1417,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Working folder': '工作文件夹', 'Working folder: {folder}': '工作文件夹:{folder}', Folder: '文件夹', -@@ -838,6 +1311,32 @@ +@@ -838,6 +1311,50 @@ 'Up one level': '上一级', 'No folders inside.': '里面没有文件夹。', 'Some folders are not shown.': '还有一些文件夹没有显示。', @@ -1445,6 +1445,24 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Open board': '打开看板', + 'Unbind': '解除绑定', + 'Bindings': '绑定关系', ++ // The Project settings and Members panels (#1749). ++ 'Bind a member to this Project': '把成员绑定到这个项目', ++ 'Member kind': '成员类型', ++ 'Member id': '成员 ID', ++ 'Member bound.': '成员已绑定。', ++ 'Bind': '绑定', ++ 'Member unbound.': '已解除成员的绑定。', ++ 'Project Assistant': '项目助手', ++ 'Pause the daily digest': '暂停每日摘要', ++ 'Assistant settings saved.': '助手设置已保存。', ++ 'Add to the assistant memory': '添加到助手记忆', ++ 'Memory saved.': '记忆已保存。', ++ 'Save memory': '保存记忆', ++ 'Back to board': '返回看板', ++ 'Owner': '负责人', ++ 'On the board': '在看板上', ++ 'No member is named yet.': '还没有指定成员。', ++ 'Member management is not part of this slice; owners and the members their cards name are listed here.': '成员管理不在本次范围内;这里列出项目负责人和卡片上点名的成员。', + 'No cards yet.': '还没有卡片。', + 'Open Projects in Settings': '在设置中打开项目', } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index fd25587fd..0a3dbdbe2 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -61,7 +61,7 @@ diff --git a/client.js b/client.js // Shared "Main Bot": "主 Bot", Working: "工作中", -@@ -49,6 +100,67 @@ +@@ -49,6 +49,67 @@ "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -129,7 +129,7 @@ diff --git a/client.js b/client.js "New…": "新建", "Animation": "动画", "Bot animation": "Bot 动画", -@@ -63,6 +175,21 @@ +@@ -63,6 +63,21 @@ Duplicate: "复制一份", Hidden: "已隐藏", Admin: "管理员", @@ -151,7 +151,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +205,72 @@ +@@ -78,21 +78,72 @@ " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -227,7 +227,7 @@ diff --git a/client.js b/client.js Connectors: "连接器", "Search connectors": "搜索连接器", "No connectors": "暂无连接器", -@@ -129,6 +307,59 @@ +@@ -129,6 +129,59 @@ "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", @@ -287,7 +287,7 @@ diff --git a/client.js b/client.js Connected: "已连接", Disconnect: "断开", Connect: "连接", -@@ -141,17 +372,64 @@ +@@ -141,17 +141,64 @@ "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", @@ -356,7 +356,7 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", -@@ -162,8 +440,6 @@ +@@ -162,8 +162,6 @@ "Dismiss new messages": "关闭新消息提示", New: "新", // New chat @@ -365,7 +365,7 @@ diff --git a/client.js b/client.js "Add to group chat": "加入群聊", "Message Bot": "给 Bot 发消息", "To:": "发给:", -@@ -187,9 +463,24 @@ +@@ -187,9 +187,24 @@ Everyone: "所有人", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", @@ -390,7 +390,7 @@ diff --git a/client.js b/client.js "Remove {name} from the group": "把 {name} 移出群聊", "Remove from group": "移出群聊", "Add member": "添加成员", -@@ -205,6 +496,16 @@ +@@ -205,6 +205,16 @@ "Close details": "关闭详情", "Copied conversation": "已复制对话", "Share conversation": "分享对话", @@ -407,7 +407,7 @@ diff --git a/client.js b/client.js "Copy conversation as Markdown": "把对话复制为 Markdown", "This conversation is not part of your Bot team.": "这个对话不属于你的 Bot 团队。", "Admin {name}": "管理员 {name}", -@@ -247,6 +548,25 @@ +@@ -247,6 +247,25 @@ "This model is not available now. Pick another one.": "这个模型现在不可用,请换一个。", "Every message of this Bot runs on it.": "这个 Bot 的每条消息都用它。", "Applies from the next message.": "从下一条消息开始生效。", @@ -433,7 +433,7 @@ diff --git a/client.js b/client.js Profile: "资料", Label: "标签", "Optional. Shown under the name.": "可选,显示在名字下方。", -@@ -270,6 +590,7 @@ +@@ -270,6 +270,7 @@ "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", Manage: "管理", @@ -441,7 +441,7 @@ diff --git a/client.js b/client.js // Group chat settings "Leave it empty to name the group after its members.": "留空就按成员命名。", "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", -@@ -303,6 +624,8 @@ +@@ -303,6 +303,8 @@ "Harness default": "Harness 默认", Saving: "保存中", "Manage models": "管理模型", @@ -450,7 +450,7 @@ diff --git a/client.js b/client.js // Shapes Whale: "鲸鱼", Star: "星星", -@@ -374,9 +697,10 @@ +@@ -374,9 +374,10 @@ "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", "Back to group chats": "返回群聊列表", @@ -463,7 +463,7 @@ diff --git a/client.js b/client.js "Harness settings": "Harness 设置", "Back to Bots": "返回 Bot 列表", "Close settings": "关闭设置", -@@ -385,7 +709,6 @@ +@@ -385,7 +385,6 @@ Pin: "置顶", Pinned: "已置顶", "Mark as Unread": "标为未读", @@ -471,7 +471,7 @@ diff --git a/client.js b/client.js "Group settings": "群设置", "Rename group": "重命名群聊", "Copy conversation ID": "复制对话 ID", -@@ -394,6 +717,8 @@ +@@ -394,6 +394,8 @@ // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", @@ -480,7 +480,7 @@ diff --git a/client.js b/client.js "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", -@@ -404,13 +729,13 @@ +@@ -404,13 +404,13 @@ "{n} h": "{n} 小时", "{n} d": "{n} 天", // Plugin updates @@ -500,7 +500,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +745,77 @@ +@@ -420,8 +420,77 @@ "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -579,7 +579,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,10 +840,13 @@ +@@ -446,10 +446,13 @@ Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -593,7 +593,7 @@ diff --git a/client.js b/client.js "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", "Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试", -@@ -459,6 +856,9 @@ +@@ -459,6 +459,9 @@ "Replies from": "群聊回复:", "Reply from": "收到回复:", "Message from": "收到消息:", @@ -603,7 +603,7 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -468,6 +868,8 @@ +@@ -468,6 +468,8 @@ "Working out a plan": "理一下思路", "Sorting out the details": "梳理细节", "On it": "在做了", @@ -612,7 +612,7 @@ diff --git a/client.js b/client.js "Busy with it": "正在处理", "Moving ahead": "推进中", "Taking care of it": "处理中", -@@ -495,7 +897,6 @@ +@@ -495,7 +495,6 @@ "Looking back through the chat": "翻看聊天记录", "{name} looked up its own chat": "{name} 查看了自己的对话", "{name} looked up its own chat for “{text}”": "{name} 在自己的对话里查找「{text}」", @@ -620,7 +620,7 @@ diff --git a/client.js b/client.js "{name} didn't answer in time": "{name} 没有及时回应", "{name} paused for your approval": "{name} 停下来等你批准", "{name} started on it": "{name} 开始做了", -@@ -506,12 +907,31 @@ +@@ -506,12 +506,31 @@ "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -653,7 +653,7 @@ diff --git a/client.js b/client.js "Couldn't message {name}": "没能给 {name} 发消息", Messaged: "已发消息给", "Couldn't stop {name}": "没能叫停 {name}", -@@ -533,13 +953,14 @@ +@@ -533,13 +533,14 @@ Selected: "已选", Skip: "跳过", Submit: "提交", @@ -669,7 +669,7 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", -@@ -555,7 +976,6 @@ +@@ -555,7 +555,6 @@ "At least {count} characters.": "至少 {count} 个字符。", "Replace value": "替换值", "Saving…": "保存中…", @@ -677,7 +677,7 @@ diff --git a/client.js b/client.js "Allowing…": "允许中…", Allow: "允许", "{bot} asked for {name}": "{bot} 请求了 {name}", -@@ -582,7 +1002,7 @@ +@@ -582,7 +582,7 @@ "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", Value: "值", @@ -686,7 +686,7 @@ diff --git a/client.js b/client.js "Save key": "保存密钥", "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", -@@ -621,7 +1041,6 @@ +@@ -621,7 +621,6 @@ Remove: "删除", "Ask or update": "询问或更新", "Ask about or update {name}'s memory": "询问或更新 {name} 的记忆", @@ -694,7 +694,7 @@ diff --git a/client.js b/client.js "Added: {text}": "已添加:{text}", "Added to team memory: {text}": "已加入团队记忆:{text}", "Updated: {text}": "已更新:{text}", -@@ -747,11 +1166,11 @@ +@@ -747,11 +747,11 @@ "Compared with the {count} days before": "与再往前 {count} 天相比", "{share}% of all usage from {span}": "占 {span} 全部用量的 {share}%", // About @@ -709,7 +709,7 @@ diff --git a/client.js b/client.js Updates: "更新", "Check for updates": "检查更新", "Checking…": "正在检查…", -@@ -761,6 +1180,13 @@ +@@ -761,6 +761,13 @@ "Update checks are off.": "已关闭更新检查。", "Installed from a local folder; update it there.": "从本地文件夹安装,请在那里更新。", "Updates are not offered for this install.": "这种安装方式不提供更新。", @@ -723,7 +723,7 @@ diff --git a/client.js b/client.js Links: "链接", "Source code": "源代码", "Release notes": "发布说明", -@@ -776,7 +1202,7 @@ +@@ -776,7 +776,7 @@ Show: "查看", Hide: "收起", "GitHub issue": "GitHub issue", @@ -732,7 +732,7 @@ diff --git a/client.js b/client.js "Report on GitHub": "在 GitHub 上报告", "Copied the diagnostics.": "已复制诊断信息。", "Could not copy. Select the text below and copy it.": "无法复制。请选中下面的文字手动复制。", -@@ -788,7 +1214,7 @@ +@@ -788,7 +788,7 @@ "Recent errors": "最近的错误", "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", @@ -741,7 +741,7 @@ diff --git a/client.js b/client.js "Model call": "模型调用", "This window": "这个窗口", "Error log": "错误日志", -@@ -821,12 +1247,21 @@ +@@ -821,12 +821,21 @@ "Add at least one more Bot": "再至少添加一个 Bot", Members: "成员", // Connectors and scheduled tasks @@ -763,7 +763,7 @@ diff --git a/client.js b/client.js "Once, {time}": "一次,{time}", "Every day at {time}": "每天 {time}", "Every {days} at {time}": "每{days} {time}", -@@ -834,7 +1269,21 @@ +@@ -834,7 +834,21 @@ "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", @@ -785,7 +785,7 @@ diff --git a/client.js b/client.js "in {name}": "在 {name}", "Click again to delete": "再点一次删除", "Delete task": "删除任务", -@@ -844,6 +1293,7 @@ +@@ -844,6 +844,7 @@ minutes: "分钟", hours: "小时", days: "天", @@ -793,7 +793,7 @@ diff --git a/client.js b/client.js "Morning briefing": "早间简报", "What to do": "要做什么", "Sum up what changed since yesterday and what needs me today.": "总结一下昨天以来的变化,以及今天需要我处理的事。", -@@ -856,9 +1306,20 @@ +@@ -856,9 +856,20 @@ Unit: "单位", "Create task": "创建任务", "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", @@ -816,7 +816,7 @@ diff --git a/client.js b/client.js "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", "Automation tasks are off in DSH": "DSH 的「自动化任务」没有打开", -@@ -869,10 +1330,19 @@ +@@ -869,10 +869,19 @@ "Pick a date and time": "选一个日期和时间", "Pick a time in the future": "选一个将来的时间", "Pick a time of day": "选一个时间", @@ -836,7 +836,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -887,7 +1357,33 @@ +@@ -887,7 +887,51 @@ "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", @@ -866,12 +866,30 @@ diff --git a/client.js b/client.js + "Open board": "打开看板", + "Unbind": "解除绑定", + "Bindings": "绑定关系", ++ // The Project settings and Members panels (#1749). ++ "Bind a member to this Project": "把成员绑定到这个项目", ++ "Member kind": "成员类型", ++ "Member id": "成员 ID", ++ "Member bound.": "成员已绑定。", ++ "Bind": "绑定", ++ "Member unbound.": "已解除成员的绑定。", ++ "Project Assistant": "项目助手", ++ "Pause the daily digest": "暂停每日摘要", ++ "Assistant settings saved.": "助手设置已保存。", ++ "Add to the assistant memory": "添加到助手记忆", ++ "Memory saved.": "记忆已保存。", ++ "Save memory": "保存记忆", ++ "Back to board": "返回看板", ++ "Owner": "负责人", ++ "On the board": "在看板上", ++ "No member is named yet.": "还没有指定成员。", ++ "Member management is not part of this slice; owners and the members their cards name are listed here.": "成员管理不在本次范围内;这里列出项目负责人和卡片上点名的成员。", + "No cards yet.": "还没有卡片。", + "Open Projects in Settings": "在设置中打开项目" }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], -@@ -900,8 +1396,11 @@ +@@ -900,8 +900,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 个字符"], @@ -883,7 +901,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 +1429,20 @@ +@@ -930,6 +930,20 @@ "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -904,7 +922,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 +1464,12 @@ +@@ -951,10 +951,12 @@ "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -919,7 +937,7 @@ diff --git a/client.js b/client.js var service = null; var bound = null; function installLocale(locale) { -@@ -1005,15 +1520,23 @@ +@@ -1005,17 +1005,25 @@ } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; @@ -935,18 +953,20 @@ diff --git a/client.js b/client.js } - entries.push({ time: now, level: "error", text, ...code ? { code } : {} }); + entries.push({ time: now, level: "error", text: text2, ...code ? { code } : {} }); -+ if (entries.length > MAX) entries.splice(0, entries.length - MAX); -+} + if (entries.length > MAX) entries.splice(0, entries.length - MAX); + } +function noteNavigation(how, target, from) { + if (typeof navigator === "undefined" || navigator.userActivation?.isActive !== false) return; + const stack = String(new Error().stack ?? "").split("\n").slice(2, 6).map((line2) => line2.trim().replace(/\(?https?:\/\/[^/\s)]+/, "(")).join(" < "); + const text2 = `navigation: ${how} to ${String(target).slice(0, 60)}${from ? ` from ${String(from).slice(0, 60)}` : ""} with no click (${stack.slice(0, 300)})`; + console.warn(`[flybot] ${text2}`); + entries.push({ time: Date.now(), level: "warn", text: text2 }); - if (entries.length > MAX) entries.splice(0, entries.length - MAX); - } ++ if (entries.length > MAX) entries.splice(0, entries.length - MAX); ++} var quietly = (where) => (error) => noteClientError(where, error); -@@ -1042,16 +1565,21 @@ + var clearClientErrors = () => { + entries.length = 0; +@@ -1042,16 +1042,21 @@ }; function failureLabel(code, message) { if (FAILURES[code]) return t(FAILURES[code]); @@ -974,7 +994,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 +1594,16 @@ +@@ -1066,10 +1066,16 @@ var call = async (endpoint, payload, signal) => { let response; try { @@ -992,7 +1012,7 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1087,7 +1621,7 @@ +@@ -1087,7 +1087,7 @@ if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); @@ -1001,7 +1021,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 +1631,388 @@ +@@ -1097,6 +1097,388 @@ return result.value; }; @@ -1390,7 +1410,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1160,9 +2076,9 @@ +@@ -1160,9 +1160,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); @@ -1402,7 +1422,7 @@ diff --git a/client.js b/client.js return value >>> 0; } function defaultColor(id) { -@@ -1177,19 +2093,96 @@ +@@ -1177,19 +1177,96 @@ value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1502,7 +1522,7 @@ diff --git a/client.js b/client.js ``, `` ].join(""); -@@ -1206,8 +2199,8 @@ +@@ -1206,8 +1206,8 @@ return ""; } } @@ -1513,7 +1533,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 +2235,46 @@ +@@ -1242,192 +1242,46 @@ } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1536,7 +1556,16 @@ diff --git a/client.js b/client.js - for (const listener of [...listeners2]) listener(); - } - }; --} ++// 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 EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; -var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); -function indexRoster(value) { @@ -1567,16 +1596,7 @@ 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"]; @@ -1725,7 +1745,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 +2292,86 @@ +@@ -1445,24 +1445,86 @@ } return spans; } @@ -1750,7 +1770,7 @@ diff --git a/client.js b/client.js + } + return missing; +} -+ + +// src/shared/preview.js +var FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi; +var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi; @@ -1776,7 +1796,7 @@ diff --git a/client.js b/client.js + } + return stripFence(out).replace(/\s+/g, " ").trim(); +} - ++ // src/client/text.js var MENTION_ORIGIN = "https://bots.local/"; -function linkMentions(text, bots, selfId) { @@ -1817,7 +1837,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 +2392,18 @@ +@@ -1483,9 +1483,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; @@ -1839,7 +1859,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 +2411,121 @@ +@@ -1493,38 +1493,121 @@ return []; } } @@ -1968,7 +1988,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,17 +2598,46 @@ +@@ -1597,17 +1597,46 @@ } return { count, peerIds }; } @@ -2018,7 +2038,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,9 +2654,982 @@ +@@ -1624,9 +1624,982 @@ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -3002,7 +3022,7 @@ diff --git a/client.js b/client.js var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); -@@ -1659,12 +3662,12 @@ +@@ -1659,12 +1659,12 @@ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); @@ -3018,7 +3038,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 +3702,10 @@ +@@ -1699,10 +1699,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 } = {}) { @@ -3032,7 +3052,7 @@ diff --git a/client.js b/client.js if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +3739,7 @@ +@@ -1736,7 +1736,7 @@ springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; @@ -3041,7 +3061,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 +3753,7 @@ +@@ -1750,7 +1750,7 @@ } place(fresh); }); @@ -3050,7 +3070,7 @@ diff --git a/client.js b/client.js own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +3761,16 @@ +@@ -1758,16 +1758,16 @@ } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -3071,7 +3091,7 @@ diff --git a/client.js b/client.js const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +3791,8 @@ +@@ -1788,8 +1788,8 @@ }, [key]); } function useMorph(box, key) { @@ -3082,7 +3102,7 @@ diff --git a/client.js b/client.js const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +3820,8 @@ +@@ -1817,8 +1817,8 @@ }); } function useLinger(value, ms) { @@ -3093,7 +3113,7 @@ diff --git a/client.js b/client.js if (value) { setKept(value); return void 0; -@@ -1838,6 +3841,7 @@ +@@ -1838,6 +1838,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)} @@ -3101,7 +3121,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 +3860,14 @@ +@@ -1856,6 +1856,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} @@ -3116,7 +3136,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 +3945,23 @@ +@@ -1933,15 +1933,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} @@ -3142,7 +3162,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 +3970,32 @@ +@@ -1950,27 +1950,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} @@ -3179,7 +3199,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 +4017,7 @@ +@@ -1992,6 +1992,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} @@ -3187,7 +3207,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 +4111,7 @@ +@@ -2085,6 +2085,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} @@ -3195,7 +3215,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 +4125,9 @@ +@@ -2098,7 +2098,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} @@ -3206,7 +3226,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 +4156,10 @@ +@@ -2127,6 +2127,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} @@ -3217,7 +3237,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 +4183,11 @@ +@@ -2150,11 +2150,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} @@ -3231,7 +3251,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 +4196,8 @@ +@@ -2163,6 +2163,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} @@ -3240,7 +3260,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 +4232,7 @@ +@@ -2197,7 +2197,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)} @@ -3249,7 +3269,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 +4272,23 @@ +@@ -2237,9 +2237,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. */ @@ -3274,7 +3294,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 +4304,7 @@ +@@ -2255,6 +2255,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} @@ -3282,7 +3302,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 +4338,63 @@ +@@ -2288,13 +2288,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} @@ -3346,7 +3366,7 @@ diff --git a/client.js b/client.js /* Settings → Connectors. */ .bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -@@ -2308,8 +4408,17 @@ +@@ -2308,8 +2308,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} @@ -3364,7 +3384,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 +4470,7 @@ +@@ -2361,7 +2361,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} @@ -3373,7 +3393,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 +4489,7 @@ +@@ -2380,7 +2380,7 @@ .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} @@ -3382,7 +3402,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 +4499,7 @@ +@@ -2390,7 +2390,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} @@ -3391,7 +3411,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 +4536,8 @@ +@@ -2427,7 +2427,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}} @@ -3401,7 +3421,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 +4604,7 @@ +@@ -2494,7 +2494,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} @@ -3410,7 +3430,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 +4707,20 @@ +@@ -2597,9 +2597,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%)} @@ -3433,7 +3453,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 +4733,6 @@ +@@ -2612,7 +2612,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} @@ -3441,7 +3461,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 +4742,48 @@ +@@ -2622,21 +2622,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} @@ -3498,7 +3518,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 +4793,19 @@ +@@ -2646,8 +2646,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} @@ -3518,7 +3538,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 +4837,8 @@ +@@ -2679,7 +2679,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} @@ -3528,7 +3548,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 +4850,8 @@ +@@ -2691,6 +2691,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} @@ -3537,7 +3557,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 +4883,9 @@ +@@ -2722,7 +2722,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} @@ -3547,7 +3567,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 +4939,7 @@ +@@ -2776,6 +2776,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} @@ -3555,7 +3575,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 +4948,10 @@ +@@ -2784,10 +2784,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)} `; @@ -3568,7 +3588,7 @@ diff --git a/client.js b/client.js document.head.appendChild(style); return style; } -@@ -2807,9 +4971,9 @@ +@@ -2807,9 +2807,9 @@ } return names; } @@ -3580,7 +3600,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 +4982,7 @@ +@@ -2818,7 +2818,7 @@ return compiled.get(fragment); }); } @@ -3589,7 +3609,7 @@ diff --git a/client.js b/client.js const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); const render = () => { -@@ -2832,7 +4996,7 @@ +@@ -2832,7 +2832,7 @@ } for (const name of list) names.add(name); } @@ -3598,7 +3618,7 @@ diff --git a/client.js b/client.js if (style.textContent !== next) style.textContent = next; }; render(); -@@ -2855,226 +5019,204 @@ +@@ -2855,226 +2855,204 @@ }; } @@ -3638,7 +3658,12 @@ 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); @@ -3668,40 +3693,58 @@ 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 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 AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+// 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 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); @@ -3718,7 +3761,9 @@ 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); + } -function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); - if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); @@ -3732,7 +3777,13 @@ 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]]; @@ -3740,76 +3791,6 @@ 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; @@ -3842,12 +3823,26 @@ 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 () => { + }; @@ -3877,7 +3872,25 @@ 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); @@ -3980,7 +3993,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 +5228,7 @@ +@@ -3086,6 +3086,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"]); @@ -3988,7 +4001,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 +5236,14 @@ +@@ -3093,14 +3093,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() { @@ -4007,7 +4020,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -3112,21 +5255,48 @@ +@@ -3112,21 +3112,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"]); @@ -4059,7 +4072,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 +5316,10 @@ +@@ -3146,10 +3146,10 @@ }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -4074,7 +4087,7 @@ diff --git a/client.js b/client.js const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +5337,7 @@ +@@ -3167,7 +3167,7 @@ }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -4083,7 +4096,7 @@ diff --git a/client.js b/client.js ref: ownRef, rows: 1, value: own, -@@ -3186,7 +5356,7 @@ +@@ -3186,7 +3186,7 @@ }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -4092,7 +4105,7 @@ diff --git a/client.js b/client.js "button", { key: option.label, -@@ -3196,36 +5366,36 @@ +@@ -3196,36 +3196,36 @@ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -4142,7 +4155,7 @@ diff --git a/client.js b/client.js "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +5405,24 @@ +@@ -3235,24 +3235,24 @@ submit(); } }, @@ -4177,7 +4190,7 @@ diff --git a/client.js b/client.js ) : null, foot ); -@@ -3262,29 +5432,29 @@ +@@ -3262,29 +3262,29 @@ const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -4218,7 +4231,7 @@ diff --git a/client.js b/client.js ); })) ); -@@ -3292,8 +5462,8 @@ +@@ -3292,8 +3292,8 @@ function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -4229,7 +4242,7 @@ diff --git a/client.js b/client.js const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +5475,10 @@ +@@ -3305,10 +3305,10 @@ } pending.answer({ answers: next }); }; @@ -4242,7 +4255,7 @@ diff --git a/client.js b/client.js if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +5488,10 @@ +@@ -3318,10 +3318,10 @@ setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -4255,7 +4268,7 @@ diff --git a/client.js b/client.js ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +5499,16 @@ +@@ -3329,16 +3329,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; @@ -4275,7 +4288,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,37 +5516,37 @@ +@@ -3346,37 +3346,37 @@ } // src/client/secret-card.js @@ -4325,7 +4338,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 +5571,42 @@ +@@ -3401,42 +3401,42 @@ }); } function ScopeSwitch({ value, onChange, bot }) { @@ -4385,7 +4398,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 +5622,73 @@ +@@ -3452,73 +3452,73 @@ if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -4485,7 +4498,7 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +5701,46 @@ +@@ -3531,44 +3531,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; @@ -4548,7 +4561,7 @@ diff --git a/client.js b/client.js const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +5755,66 @@ +@@ -3583,66 +3583,66 @@ setValue(next); setReplaced(false); }; @@ -4641,7 +4654,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 +5825,43 @@ +@@ -3653,43 +3653,43 @@ } }); }; @@ -4703,7 +4716,7 @@ diff --git a/client.js b/client.js ]; } var SECRET_CSS = ` -@@ -3756,6 +5928,144 @@ +@@ -3756,6 +3756,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}} `; @@ -4848,7 +4861,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 +6077,7 @@ +@@ -3767,7 +3767,7 @@ var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -4857,7 +4870,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 +6090,29 @@ +@@ -3780,29 +3780,29 @@ actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -4895,7 +4908,7 @@ diff --git a/client.js b/client.js key: emoji, type: "button", className: "bt-react", -@@ -3813,9 +6123,22 @@ +@@ -3813,9 +3813,22 @@ }) ); } @@ -4919,7 +4932,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 +6158,12 @@ +@@ -3835,12 +3835,12 @@ }; }, [open]); } @@ -4938,7 +4951,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 +6177,36 @@ +@@ -3854,36 +3854,36 @@ setPanel(kind); }; const copy2 = () => { @@ -4986,7 +4999,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 +6214,40 @@ +@@ -3891,12 +3891,40 @@ ), document.body) : null ); } @@ -5031,7 +5044,7 @@ diff --git a/client.js b/client.js ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,19 +6255,33 @@ +@@ -3904,19 +3904,33 @@ const location2 = node.location; const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; const seq = node.data.finalNode?.seq; @@ -5067,7 +5080,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 +6289,14 @@ +@@ -3924,13 +3924,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; @@ -5085,7 +5098,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 +6306,230 @@ +@@ -3940,61 +3940,230 @@ whole: true }); }), @@ -5093,8 +5106,8 @@ diff --git a/client.js b/client.js - texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null + reasoning ? (0, import_react9.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles -+ ); -+} + ); + } +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -5113,8 +5126,8 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) - ); - } ++ ); ++} function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { if (whole && bots.length === 1) { - return (0, import_react6.createElement)( @@ -5151,7 +5164,9 @@ 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) { @@ -5164,9 +5179,7 @@ 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; @@ -5280,8 +5293,8 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text2)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+} + ); + } +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -5325,8 +5338,8 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null - ); - } ++ ); ++} function GroupEvent({ prefix, room: room2, name, roster, actions }) { - return (0, import_react6.createElement)( + return (0, import_react9.createElement)( @@ -5339,7 +5352,7 @@ diff --git a/client.js b/client.js ); } function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +6540,14 @@ +@@ -4005,14 +4005,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 }); @@ -5357,7 +5370,7 @@ diff --git a/client.js b/client.js } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +6556,11 @@ +@@ -4021,11 +4021,11 @@ return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -5374,7 +5387,7 @@ diff --git a/client.js b/client.js const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +6576,19 @@ +@@ -4041,19 +4041,19 @@ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -5397,7 +5410,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 +6604,35 @@ +@@ -4069,35 +4069,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); } @@ -5444,7 +5457,7 @@ diff --git a/client.js b/client.js if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +6640,7 @@ +@@ -4105,7 +4105,7 @@ return null; } }), @@ -5453,7 +5466,7 @@ diff --git a/client.js b/client.js ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +6651,7 @@ +@@ -4116,7 +4116,7 @@ const deferred = source.kind === "bot" && source.deferred === true; const relayed = isRelayContext(data); const turn = node.location?.turn?.turn ?? data.turn; @@ -5462,7 +5475,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 +6670,31 @@ +@@ -4135,23 +4135,31 @@ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -5501,7 +5514,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 +6708,7 @@ +@@ -4165,7 +4165,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); } @@ -5510,7 +5523,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 +6717,24 @@ +@@ -4174,24 +4174,24 @@ } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -5540,7 +5553,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 +6742,67 @@ +@@ -4199,11 +4199,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; }); @@ -5609,7 +5622,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 +6832,19 @@ +@@ -4233,10 +4233,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; @@ -5630,7 +5643,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +6858,15 @@ +@@ -4250,15 +4250,15 @@ return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -5651,16 +5664,16 @@ 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 +6928,22 @@ +@@ -4320,19 +4320,22 @@ } function workPhrase(tool) { if (!tool) return t("Thinking about it"); - return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); -+} + } +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; - } ++} function useLiveNote(actions, sessionId, id, entry) { const key = entry ? JSON.stringify(entry) : ""; - (0, import_react6.useEffect)(() => { @@ -5679,7 +5692,7 @@ diff --git a/client.js b/client.js if (done) return void 0; let stop = false; let timer; -@@ -4341,10 +6952,10 @@ +@@ -4341,10 +4341,10 @@ const result = await actions.roomProgress(sessionId); if (stop) return; const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; @@ -5692,7 +5705,7 @@ diff --git a/client.js b/client.js } timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -@@ -4362,71 +6973,89 @@ +@@ -4362,71 +4362,89 @@ replies = []; } } @@ -5813,7 +5826,7 @@ diff --git a/client.js b/client.js }) ); } -@@ -4435,52 +7064,52 @@ +@@ -4435,52 +4435,52 @@ function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -5882,7 +5895,7 @@ diff --git a/client.js b/client.js case "create_group": case "update_group": case "delete_group": -@@ -4492,64 +7121,90 @@ +@@ -4492,64 +4492,90 @@ post_to_group: ["Posted in", "Couldn't post in the group"] }[tool]; if (!done) return null; @@ -5957,8 +5970,8 @@ diff --git a/client.js b/client.js - return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); -+} -+ + } + +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { @@ -5983,15 +5996,15 @@ diff --git a/client.js b/client.js + if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; + if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); + return root.querySelector(RUNNING_HOST); - } - ++} ++ // src/client/activity-row.js -var import_react7 = require("react"); +var import_react10 = require("react"); 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 +7214,69 @@ +@@ -4559,9 +4559,69 @@ const hours = Math.floor(minutes / 60); return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } @@ -6064,7 +6077,7 @@ diff --git a/client.js b/client.js if (!active) { setHost(null); return void 0; -@@ -4569,7 +7284,7 @@ +@@ -4569,7 +4569,7 @@ let frame = 0; const find = () => { frame = 0; @@ -6073,7 +6086,7 @@ diff --git a/client.js b/client.js setHost((prev) => prev === node ? prev : node); }; const observer = new MutationObserver(() => { -@@ -4581,13 +7296,13 @@ +@@ -4581,13 +4581,13 @@ cancelAnimationFrame(frame); observer.disconnect(); }; @@ -6090,7 +6103,7 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4638,21 +7353,31 @@ +@@ -4638,21 +4638,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]; @@ -6129,7 +6142,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 +7388,31 @@ +@@ -4663,26 +4663,31 @@ const timer = setTimeout(make, wait); return () => clearTimeout(timer); }, [wantKey, shown]); @@ -6173,7 +6186,7 @@ diff --git a/client.js b/client.js setShown(null); const before = shift.current; shift.current = null; -@@ -4691,31 +7421,37 @@ +@@ -4691,31 +4691,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]); @@ -6223,7 +6236,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +7467,1697 @@ +@@ -4731,49 +4731,1697 @@ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } @@ -7934,7 +7947,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +9169,131 @@ +@@ -4785,55 +4785,131 @@ onClick: () => actions.openSession(id), onContextMenu }, @@ -7985,7 +7998,7 @@ diff --git a/client.js b/client.js ) : null, - wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + unreadMark -+ ); + ); + const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); + const settings = wide ? (0, import_react16.createElement)("button", { + type: "button", @@ -8001,7 +8014,7 @@ diff --git a/client.js b/client.js + }, (0, import_react16.createElement)(GearIcon)) : null; + if (settings) return (0, import_react16.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); + return row; -+} + } +function keepSidebarStill(node) { + const reset = () => { + for (let at = node?.parentElement; at; at = at.parentElement) if (at.scrollLeft > 0) at.scrollLeft = 0; @@ -8041,8 +8054,8 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react16.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) - ); - } ++ ); ++} function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room2.id; - const fallback = useSessions((list) => previewOf(list, id)); @@ -8081,7 +8094,7 @@ diff --git a/client.js b/client.js "data-room-id": id, onClick: () => actions.openSession(id), onContextMenu: (event) => { -@@ -4841,20 +9301,20 @@ +@@ -4841,20 +4841,20 @@ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -8110,7 +8123,7 @@ diff --git a/client.js b/client.js if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +9335,10 @@ +@@ -4875,10 +4875,10 @@ } })(); }; @@ -8123,7 +8136,7 @@ diff --git a/client.js b/client.js ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +9351,7 @@ +@@ -4891,7 +4891,7 @@ }) ); } @@ -8132,7 +8145,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4905,8 +9365,8 @@ +@@ -4905,8 +4905,8 @@ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8143,7 +8156,7 @@ diff --git a/client.js b/client.js ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +9401,10 @@ +@@ -4941,10 +4941,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; @@ -8156,7 +8169,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4959,30 +9419,30 @@ +@@ -4959,30 +4959,30 @@ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8198,7 +8211,7 @@ diff --git a/client.js b/client.js key: session.id, session, agent, -@@ -4996,8 +9456,8 @@ +@@ -4996,8 +4996,8 @@ } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -8209,7 +8222,7 @@ diff --git a/client.js b/client.js const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +9474,44 @@ +@@ -5014,9 +5014,44 @@ tops.current = { layout, map: next }; }, [layout, key]); } @@ -8255,7 +8268,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 +9520,26 @@ +@@ -5025,17 +5025,26 @@ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -8286,7 +8299,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 +9549,42 @@ +@@ -5045,39 +5045,42 @@ event.preventDefault(); actions.openPalette(event.key); }; @@ -8349,7 +8362,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 +9623,14 @@ +@@ -5116,14 +5116,14 @@ setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -8369,7 +8382,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +9640,21 @@ +@@ -5133,21 +5133,21 @@ "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -8402,7 +8415,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +9662,17 @@ +@@ -5155,17 +5155,17 @@ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -8427,7 +8440,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5176,61 +9683,25 @@ +@@ -5176,61 +5176,25 @@ ), document.body) : null ); } @@ -8499,7 +8512,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +9730,18 @@ +@@ -5259,6 +5259,18 @@ else openHarnessSettings(); }); } @@ -8518,7 +8531,7 @@ diff --git a/client.js b/client.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')); trigger.click(); -@@ -5288,32 +9771,32 @@ +@@ -5288,32 +5288,32 @@ })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -8559,7 +8572,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 +9824,32 @@ +@@ -5341,30 +5341,32 @@ const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -8604,7 +8617,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -5372,7 +9857,7 @@ +@@ -5372,7 +5372,7 @@ setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -8613,7 +8626,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +9867,8 @@ +@@ -5382,8 +5382,8 @@ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -8624,7 +8637,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 +9882,40 @@ +@@ -5397,40 +5397,40 @@ return void 0; } const last = lastBot.current; @@ -8675,7 +8688,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5456,7 +9941,7 @@ +@@ -5456,7 +5456,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)} @@ -8684,7 +8697,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 +9976,140 @@ +@@ -5491,18 +5491,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}} @@ -8830,7 +8843,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 +10119,19 @@ +@@ -5512,19 +5512,19 @@ return shown; } function MemoryNotice({ shown }) { @@ -8856,7 +8869,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 +10144,7 @@ +@@ -5537,7 +5537,7 @@ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -8865,7 +8878,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 +10155,41 @@ +@@ -5548,52 +5548,41 @@ const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -8915,15 +8928,14 @@ 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 }), @@ -8938,7 +8950,7 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5642,7 +10238,7 @@ +@@ -5642,7 +5642,7 @@ }, []); return view; } @@ -8947,7 +8959,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 +10254,22 @@ +@@ -5658,18 +5658,22 @@ }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -8975,7 +8987,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 +10277,16 @@ +@@ -5677,16 +5677,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"); @@ -8996,7 +9008,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +10294,7 @@ +@@ -5694,7 +5694,7 @@ event.stopPropagation(); onDismiss(); } @@ -9005,7 +9017,7 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5703,9 +10303,9 @@ +@@ -5703,9 +5703,9 @@ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); @@ -9018,7 +9030,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 +10314,135 @@ +@@ -5714,19 +5714,135 @@ ); } @@ -9162,7 +9174,7 @@ diff --git a/client.js b/client.js if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5745,10 +10461,10 @@ +@@ -5745,10 +5745,10 @@ let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; @@ -9176,7 +9188,7 @@ diff --git a/client.js b/client.js } if (interim) latest.current(interim, false); }; -@@ -5774,19 +10490,38 @@ +@@ -5774,19 +5774,38 @@ const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -9220,7 +9232,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 +10532,10 @@ +@@ -5797,10 +5797,10 @@ }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -9234,7 +9246,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 +10543,16 @@ +@@ -5808,16 +5808,16 @@ title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -9255,7 +9267,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +10560,17 @@ +@@ -5825,17 +5825,17 @@ return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -9278,7 +9290,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 +10611,10 @@ +@@ -5876,10 +5876,10 @@ actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -9292,7 +9304,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +10624,8 @@ +@@ -5889,8 +5889,8 @@ "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -9303,7 +9315,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +10635,9 @@ +@@ -5900,9 +5900,9 @@ style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -9316,7 +9328,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +10657,57 @@ +@@ -5922,15 +5922,57 @@ function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -9378,7 +9390,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 +10736,31 @@ +@@ -5959,14 +5959,31 @@ order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -9414,7 +9426,7 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +10789,83 @@ +@@ -5995,6 +5995,83 @@ return [bots, chats]; } @@ -9498,7 +9510,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 +10879,8 @@ +@@ -6008,8 +6008,8 @@ function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); @@ -9509,7 +9521,7 @@ diff --git a/client.js b/client.js }; ascii(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); -@@ -6030,9 +10901,9 @@ +@@ -6030,9 +6030,9 @@ return bytes; } function base64Of(bytes) { @@ -9522,7 +9534,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 +10992,9 @@ +@@ -6121,233 +6121,9 @@ }; } @@ -9758,7 +9770,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS = 120; function groupModels(models) { const groups = /* @__PURE__ */ new Map(); -@@ -6367,17 +11014,10 @@ +@@ -6367,17 +6367,10 @@ }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -9778,7 +9790,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 +11042,12 @@ +@@ -6402,12 +6402,12 @@ ]; } function EffortRows({ reasoning, current, onPick }) { @@ -9796,7 +9808,7 @@ diff --git a/client.js b/client.js "button", { key: row.effort ?? "default", -@@ -6416,17 +11056,17 @@ +@@ -6416,17 +6416,17 @@ "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -9820,7 +9832,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +11076,20 @@ +@@ -6436,20 +6436,20 @@ title: model.ref, onClick: () => onPick(model.ref) }, @@ -9849,7 +9861,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 +11124,7 @@ +@@ -6484,6 +6484,7 @@ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -9857,7 +9869,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 +11152,10 @@ +@@ -6511,10 +6511,10 @@ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -9871,7 +9883,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6527,37 +11168,38 @@ +@@ -6527,37 +6527,38 @@ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -9924,7 +9936,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 +11211,13 @@ +@@ -6569,13 +6569,13 @@ void actions.refreshModels?.(); }).finally(() => setBusy("")); }; @@ -9943,7 +9955,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +11226,115 @@ +@@ -6584,50 +6584,115 @@ "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -9980,8 +9992,8 @@ diff --git a/client.js b/client.js - error ? (0, import_react13.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null + (0, import_react20.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), + error ? (0, import_react20.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -+ ); -+} + ); + } +function RemoteGoneDock({ bot }) { + return (0, import_react20.createElement)( + "div", @@ -10000,8 +10012,8 @@ diff --git a/client.js b/client.js + ) + ) + ) - ); - } ++ ); ++} function setupChoices(actions) { return [ actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null, @@ -10075,7 +10087,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +11346,16 @@ +@@ -6639,6 +6639,16 @@ }); } var MODEL_CSS = ` @@ -10092,7 +10104,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 +11392,96 @@ +@@ -6675,18 +6675,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}} `; @@ -10197,7 +10209,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 +11514,35 @@ +@@ -6719,35 +6719,35 @@ }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -10249,7 +10261,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +11554,33 @@ +@@ -6759,33 +6759,33 @@ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -10299,7 +10311,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6798,19 +11593,19 @@ +@@ -6798,19 +6798,19 @@ title: value, onClick: toggle }, @@ -10325,7 +10337,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +11625,8 @@ +@@ -6830,8 +6830,8 @@ `; // src/client/schedules.js @@ -10336,7 +10348,7 @@ diff --git a/client.js b/client.js var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +11634,8 @@ +@@ -6839,8 +6839,8 @@ for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -10347,7 +10359,7 @@ diff --git a/client.js b/client.js listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +11643,7 @@ +@@ -6848,7 +6848,7 @@ if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -10356,7 +10368,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6873,7 +11668,8 @@ +@@ -6873,7 +6873,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 }); @@ -10366,7 +10378,7 @@ diff --git a/client.js b/client.js } function timingText(task) { const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; -@@ -6887,8 +11683,8 @@ +@@ -6887,8 +6887,8 @@ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); @@ -10377,7 +10389,7 @@ diff --git a/client.js b/client.js } case "cron": return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -@@ -6896,11 +11692,25 @@ +@@ -6896,11 +6896,25 @@ return ""; } } @@ -10408,7 +10420,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 +11723,49 @@ +@@ -6913,20 +6913,49 @@ setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -10467,7 +10479,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +11773,7 @@ +@@ -6934,7 +6934,7 @@ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -10476,7 +10488,7 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6944,27 +11783,49 @@ +@@ -6944,27 +6944,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"]]; @@ -10543,7 +10555,7 @@ diff --git a/client.js b/client.js setBusy(true); setError(""); let when3; -@@ -6975,151 +11836,298 @@ +@@ -6975,151 +6975,298 @@ setBusy(false); return; } @@ -10673,8 +10685,8 @@ diff --git a/client.js b/client.js + (0, import_react23.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), + (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), + (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) -+ ); -+} + ); + } +function QuietHoursCard({ view, actions }) { + const quiet = view.quietHours ?? { on: false }; + const [busy, setBusy] = (0, import_react23.useState)(false); @@ -10738,8 +10750,8 @@ diff --git a/client.js b/client.js + error ? (0, import_react23.createElement)("div", { className: "bt-set-row" }, (0, import_react23.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, + quiet.on && unchecked ? (0, import_react23.createElement)("div", { className: "bt-set-row" }, (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null + ) - ); - } ++ ); ++} function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions); - const [adding, setAdding] = (0, import_react15.useState)(Boolean(start)); @@ -10753,11 +10765,11 @@ diff --git a/client.js b/client.js publish(next); setAdding(false); + setEditing(null); -+ }; + }; + const edit = (task) => { + setAdding(false); + setEditing(task); - }; ++ }; const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react15.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), - ...roster.rooms.map((room2) => [room2.id, (0, import_react15.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) @@ -10904,7 +10916,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 +12135,12 @@ +@@ -7127,6 +7127,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} @@ -10917,7 +10929,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 +12172,12 @@ +@@ -7158,12 +7158,12 @@ `; // src/client/capability-settings.js @@ -10933,7 +10945,7 @@ diff --git a/client.js b/client.js } catch { throw new Error(t("{label} must be valid JSON.", { label })); } -@@ -7172,11 +12186,11 @@ +@@ -7172,11 +7172,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 }) { @@ -10948,7 +10960,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +12202,7 @@ +@@ -7188,7 +7188,7 @@ ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -10957,7 +10969,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 +12213,7 @@ +@@ -7199,7 +7199,7 @@ if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -10966,7 +10978,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 +12233,95 @@ +@@ -7219,95 +7219,95 @@ } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -11102,7 +11114,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +12344,20 @@ +@@ -7330,20 +7330,20 @@ setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -11130,7 +11142,7 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +12373,10 @@ +@@ -7359,10 +7359,10 @@ const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -11143,7 +11155,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 +12386,55 @@ +@@ -7372,9 +7372,55 @@ ); } @@ -11201,7 +11213,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 +12444,13 @@ +@@ -7384,13 +7384,13 @@ for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -11218,7 +11230,7 @@ diff --git a/client.js b/client.js if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); -@@ -7404,16 +12464,16 @@ +@@ -7404,16 +7404,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 }); @@ -11241,7 +11253,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +12488,45 @@ +@@ -7428,45 +7428,45 @@ return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -11306,7 +11318,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +12538,9 @@ +@@ -7478,9 +7478,9 @@ return groups; }, []); function EntryRow({ entry, remove }) { @@ -11319,7 +11331,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 +12555,16 @@ +@@ -7495,16 +7495,16 @@ setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -11341,7 +11353,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +12572,7 @@ +@@ -7512,7 +7512,7 @@ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -11350,7 +11362,7 @@ diff --git a/client.js b/client.js ); } function Entries({ bot, view, remove }) { -@@ -7521,82 +12581,82 @@ +@@ -7521,82 +7521,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 } ]; @@ -11468,7 +11480,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,9 +12666,9 @@ +@@ -7606,9 +7606,9 @@ }, [botId]); return { view, error, load, show }; } @@ -11481,7 +11493,7 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +12676,18 @@ +@@ -7616,18 +7616,18 @@ } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -11510,7 +11522,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 +12731,7 @@ +@@ -7671,7 +7671,7 @@ }; const ask = async (event) => { event.preventDefault(); @@ -11519,7 +11531,7 @@ diff --git a/client.js b/client.js if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); -@@ -7689,65 +12749,65 @@ +@@ -7689,65 +7689,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; @@ -11612,7 +11624,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -7755,8 +12815,40 @@ +@@ -7755,8 +7755,40 @@ ); } @@ -11654,7 +11666,7 @@ diff --git a/client.js b/client.js 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 +12872,8 @@ +@@ -7780,8 +7780,8 @@ throw new Error(t("That image is too large")); } function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { @@ -11665,7 +11677,7 @@ diff --git a/client.js b/client.js const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +12884,7 @@ +@@ -7792,7 +7792,7 @@ if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -11674,7 +11686,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 +12905,29 @@ +@@ -7813,29 +7813,29 @@ }); } function Row({ label, hint, stack = false, children }) { @@ -11713,7 +11725,7 @@ diff --git a/client.js b/client.js ); } function modelHint(bot, roster) { -@@ -7846,22 +12938,22 @@ +@@ -7846,22 +7846,22 @@ return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -11741,7 +11753,7 @@ diff --git a/client.js b/client.js key: id, type: "button", className: "bt-shape", -@@ -7869,8 +12961,8 @@ +@@ -7869,8 +7869,8 @@ title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -11752,7 +11764,7 @@ diff --git a/client.js b/client.js key: color, type: "button", className: "bt-color", -@@ -7878,19 +12970,19 @@ +@@ -7878,19 +7878,19 @@ title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -11778,7 +11790,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 +13000,7 @@ +@@ -7908,7 +7908,7 @@ window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -11787,7 +11799,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 +13014,32 @@ +@@ -7922,32 +7922,32 @@ }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -11828,7 +11840,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-dropzone", -@@ -7963,15 +13055,15 @@ +@@ -7963,15 +7963,15 @@ upload(event.dataTransfer.files?.[0]); } }, @@ -11850,7 +11862,7 @@ diff --git a/client.js b/client.js const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,26 +13074,27 @@ +@@ -7982,26 +7982,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 }); @@ -11886,7 +11898,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 +13104,116 @@ +@@ -8011,112 +8011,116 @@ if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -12052,7 +12064,7 @@ diff --git a/client.js b/client.js ) ) ); -@@ -8130,9 +13227,17 @@ +@@ -8130,9 +8130,17 @@ .bt-bs .bt-avedit{left:0;transform:none} .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} @@ -12071,7 +12083,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,8 +13258,659 @@ +@@ -8153,8 +8153,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}} @@ -12731,7 +12743,7 @@ diff --git a/client.js b/client.js // src/client/bot-draft.js function freshBotName(roster, base = "New Bot") { const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase())); -@@ -8165,36 +13921,48 @@ +@@ -8165,36 +8165,48 @@ } function startModel(roster) { const models = roster.models ?? []; @@ -12795,7 +12807,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 +13974,8 @@ +@@ -8206,8 +8206,8 @@ setError(""); setBusy(true); try { @@ -12806,7 +12818,7 @@ diff --git a/client.js b/client.js } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); -@@ -8219,25 +13987,25 @@ +@@ -8219,25 +8219,25 @@ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -12840,7 +12852,7 @@ diff --git a/client.js b/client.js look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +14017,27 @@ +@@ -8249,27 +8249,27 @@ setLook((current) => ({ ...current, color })); } }), @@ -12876,7 +12888,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,75 +14052,113 @@ +@@ -8284,75 +8284,113 @@ } } }), @@ -13020,7 +13032,7 @@ diff --git a/client.js b/client.js const pick2 = (bot) => { setPicked((current) => [...current, bot.id]); setQuery(""); -@@ -8386,71 +14192,104 @@ +@@ -8386,71 +8386,104 @@ }; const created = (bot) => { close(); @@ -13145,7 +13157,7 @@ diff --git a/client.js b/client.js canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); -@@ -8467,33 +14306,41 @@ +@@ -8467,33 +8467,41 @@ if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -13202,7 +13214,7 @@ diff --git a/client.js b/client.js ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +14364,20 @@ +@@ -8517,20 +8517,20 @@ } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -13228,7 +13240,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +14389,36 @@ +@@ -8542,21 +8542,36 @@ onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -13248,7 +13260,7 @@ diff --git a/client.js b/client.js - (0, import_react20.createElement)( + menuOnly ? null : (0, import_react32.createElement)("div", { className: "bt-nc-spacer" }), + error && fix ? (0, import_react32.createElement)( -+ "div", + "div", + { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" }, + (0, import_react32.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), + (0, import_react32.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ @@ -13261,7 +13273,7 @@ diff --git a/client.js b/client.js + inviteLink ? (0, import_react32.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null + ) : error ? (0, import_react32.createElement)("div", { className: "bt-error" }, error) : null, + menuOnly ? null : (0, import_react32.createElement)( - "div", ++ "div", { className: "bt-nc-compose" }, - (0, import_react20.createElement)( + (0, import_react32.createElement)( @@ -13274,7 +13286,7 @@ diff --git a/client.js b/client.js rows: 1, value: message, placeholder, -@@ -8569,7 +14431,7 @@ +@@ -8569,7 +8569,7 @@ } } }), @@ -13283,7 +13295,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +14439,41 @@ +@@ -8577,18 +8577,41 @@ } // src/client/exchange.js @@ -13329,7 +13341,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,49 +14497,42 @@ +@@ -8612,49 +8612,42 @@ const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -13393,7 +13405,7 @@ diff --git a/client.js b/client.js var VOICE_LABELS = { listening: "Listening", thinking: "Thinking", -@@ -8665,14 +14543,14 @@ +@@ -8665,14 +8665,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) { @@ -13411,7 +13423,7 @@ diff --git a/client.js b/client.js if (stopped) { media.getTracks().forEach((track) => track.stop()); return; -@@ -8716,18 +14594,18 @@ +@@ -8716,18 +8716,18 @@ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -13441,7 +13453,7 @@ diff --git a/client.js b/client.js const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8735,24 +14613,24 @@ +@@ -8735,24 +8735,24 @@ actions.closeVoice(); }; useEscape(close); @@ -13474,7 +13486,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 +14645,16 @@ +@@ -8767,16 +8767,16 @@ }; }, [speech]); const listen = phase === "listening"; @@ -13494,7 +13506,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 +14667,9 @@ +@@ -8789,9 +8789,9 @@ setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); @@ -13506,7 +13518,7 @@ diff --git a/client.js b/client.js else setPhase("listening"); } catch (error) { if (turn.current !== own) return; -@@ -8838,12 +14716,12 @@ +@@ -8838,12 +8838,12 @@ setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -13523,7 +13535,7 @@ diff --git a/client.js b/client.js setSaid(spoken); setHeard(""); if (!window.speechSynthesis || !spoken) { -@@ -8858,7 +14736,7 @@ +@@ -8858,7 +8858,7 @@ window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -13532,7 +13544,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 +14753,663 @@ +@@ -8875,291 +8875,663 @@ const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -13613,7 +13625,24 @@ 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(); @@ -13644,24 +13673,7 @@ 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); +} @@ -14114,7 +14126,10 @@ diff --git a/client.js b/client.js - { 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 }) }) -- ) ++ { 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_react23.createElement)( - Card, @@ -14174,10 +14189,7 @@ 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-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")) - ) +- ) ) ); } @@ -14215,15 +14227,14 @@ diff --git a/client.js b/client.js "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", + "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("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 ?? "" })), @@ -14432,7 +14443,7 @@ diff --git a/client.js b/client.js // src/client/usage-data.js var HOUR = 0; -@@ -9277,13 +15527,13 @@ +@@ -9277,13 +9277,13 @@ } return result; } @@ -14450,7 +14461,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +15544,11 @@ +@@ -9294,11 +9294,11 @@ } // src/client/usage-page.js @@ -14464,7 +14475,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 +15591,9 @@ +@@ -9341,9 +9341,9 @@ return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -14477,7 +14488,7 @@ diff --git a/client.js b/client.js if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +15613,9 @@ +@@ -9363,9 +9363,9 @@ }, [value]); return shown; } @@ -14489,7 +14500,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 +15632,7 @@ +@@ -9382,7 +9382,7 @@ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -14498,7 +14509,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +15642,7 @@ +@@ -9392,7 +9392,7 @@ name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -14507,7 +14518,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9411,33 +15661,33 @@ +@@ -9411,33 +9411,33 @@ return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { @@ -14552,7 +14563,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +15705,83 @@ +@@ -9455,83 +9455,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 }) { @@ -14664,7 +14675,7 @@ diff --git a/client.js b/client.js onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +15790,69 @@ +@@ -9540,69 +9540,69 @@ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -14762,7 +14773,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 +15878,17 @@ +@@ -9628,17 +9628,17 @@ const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -14783,7 +14794,7 @@ diff --git a/client.js b/client.js key, type: "button", className: "bt-us-day", -@@ -9654,15 +15904,15 @@ +@@ -9654,15 +9654,15 @@ "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -14805,7 +14816,7 @@ diff --git a/client.js b/client.js ); }; const shortcuts = [ -@@ -9671,30 +15921,30 @@ +@@ -9671,30 +9671,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; @@ -14844,7 +14855,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 +15960,13 @@ +@@ -9710,13 +9710,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()) { @@ -14860,7 +14871,7 @@ diff --git a/client.js b/client.js key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +15974,7 @@ +@@ -9724,7 +9724,7 @@ "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -14869,7 +14880,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +15983,61 @@ +@@ -9733,61 +9733,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 }); @@ -14957,7 +14968,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 +16047,7 @@ +@@ -9797,7 +9797,7 @@ const value = await actions.usage(); await resized; if (stopped) return; @@ -14966,7 +14977,7 @@ diff --git a/client.js b/client.js setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +16064,7 @@ +@@ -9814,7 +9814,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); @@ -14975,7 +14986,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 +16073,7 @@ +@@ -9823,7 +9823,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)"; @@ -14984,7 +14995,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 +16121,23 @@ +@@ -9871,23 +9871,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]); @@ -15016,7 +15027,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +16162,7 @@ +@@ -9912,7 +9912,7 @@ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -15025,7 +15036,7 @@ diff --git a/client.js b/client.js key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +16174,7 @@ +@@ -9924,7 +9924,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 }) }); @@ -15034,7 +15045,7 @@ diff --git a/client.js b/client.js key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +16186,30 @@ +@@ -9936,30 +9936,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 }) }); @@ -15075,7 +15086,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +16218,39 @@ +@@ -9968,39 +9968,39 @@ const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -15129,7 +15140,7 @@ diff --git a/client.js b/client.js body ); } -@@ -10150,33 +16400,216 @@ +@@ -10150,33 +10150,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)}} @@ -15357,7 +15368,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,83 +16617,195 @@ +@@ -10184,83 +10184,195 @@ after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -15517,11 +15528,13 @@ diff --git a/client.js b/client.js setForm(false); setToken(""); + setValues({}); -+ setError(null); -+ } }, t("Cancel")), + setError(null); + } }, t("Cancel")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) -+ ) -+ ) : null, + ) + ) : null, +- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null + on && gateway && picking ? (0, import_react37.createElement)( + "div", + { className: "bt-connector-form" }, @@ -15533,13 +15546,11 @@ diff --git a/client.js b/client.js + (0, import_react37.createElement)("span", { className: "bt-connector-link" }), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + setPicking(false); - setError(null); - } }, t("Cancel")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) ++ setError(null); ++ } }, t("Cancel")), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) - ) - ) : null, -- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ++ ) ++ ) : null, + error ? (0, import_react37.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ); } @@ -15583,7 +15594,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 +16815,18 @@ +@@ -10270,18 +10270,18 @@ if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -15609,7 +15620,7 @@ diff --git a/client.js b/client.js void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +16834,7 @@ +@@ -10289,7 +10289,7 @@ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -15618,7 +15629,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10307,8 +16852,8 @@ +@@ -10307,8 +10307,8 @@ if (!inline && !copied) setPreview(true); })); }; @@ -15629,7 +15640,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 +16862,59 @@ +@@ -10317,59 +10317,59 @@ const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -15717,7 +15728,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +16922,8 @@ +@@ -10377,8 +10377,8 @@ var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -15728,7 +15739,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 +16934,16 @@ +@@ -10389,15 +10389,16 @@ }; }, [toast?.token]); if (!toast) return null; @@ -15750,7 +15761,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10416,47 +16962,56 @@ +@@ -10416,47 +10416,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} @@ -15821,7 +15832,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 +17021,72 @@ +@@ -10466,87 +10466,72 @@ live = false; }; }, []); @@ -15932,7 +15943,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 +17109,59 @@ +@@ -10569,54 +10569,59 @@ `; // src/client/settings.js @@ -16010,7 +16021,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +17171,86 @@ +@@ -10626,75 +10626,86 @@ style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -16120,7 +16131,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +17262,69 @@ +@@ -10706,69 +10706,69 @@ ); } function BotsPage({ roster, actions, run }) { @@ -16220,7 +16231,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +17335,250 @@ +@@ -10779,104 +10779,250 @@ }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -16512,13 +16523,13 @@ 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 +17586,22 @@ +@@ -10884,13 +10884,22 @@ const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; - const groups = [ + const groups = bot?.remote ? [ -+ [ + [ + [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] + ], + [ @@ -16527,7 +16538,7 @@ diff --git a/client.js b/client.js + pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]), + [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]] + ] : [ - [ ++ [ isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] ], @@ -16537,7 +16548,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 +17612,22 @@ +@@ -10901,20 +10901,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 ], @@ -16566,7 +16577,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +17636,14 @@ +@@ -10923,14 +10923,14 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -16585,7 +16596,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 +17665,14 @@ +@@ -10952,14 +10952,14 @@ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -16605,7 +16616,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10968,16 +17681,31 @@ +@@ -10968,16 +10968,31 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -16638,7 +16649,7 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11038,244 +17766,8 @@ +@@ -11038,244 +11038,8 @@ } } @@ -16884,7 +16895,7 @@ diff --git a/client.js b/client.js var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { try { -@@ -11289,10 +17781,10 @@ +@@ -11289,10 +11289,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 }) { @@ -16897,7 +16908,7 @@ diff --git a/client.js b/client.js if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +17794,14 @@ +@@ -11302,14 +11302,14 @@ setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -16916,7 +16927,7 @@ diff --git a/client.js b/client.js key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +17815,42 @@ +@@ -11323,42 +11323,42 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -16974,7 +16985,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +17859,195 @@ +@@ -11367,87 +11367,195 @@ void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -17006,13 +17017,16 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + 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)("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 -+ ); -+} + ); + } +function SharedRoomApprovals({ room: room2 }) { + const [items, setItems] = (0, import_react42.useState)(room2.pendingApprovals ?? []); + const [busy, setBusy] = (0, import_react42.useState)(""); @@ -17060,11 +17074,8 @@ 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, -- (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)(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, { @@ -17075,8 +17086,8 @@ diff --git a/client.js b/client.js + onOpenBot: (id) => actions.openSession(id), + lead: room2.face?.lead?.handle + }))) - ); - } ++ ); ++} function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) { + const live = room2.presentedParticipants ?? room2.participants ?? []; + const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); @@ -17095,7 +17106,7 @@ diff --git a/client.js b/client.js - (0, import_react31.createElement)( + (0, import_react42.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), + invited && room2.sharedFrom && !face ? (0, import_react42.createElement)( -+ "div", + "div", + { key: "from", className: "bt-shared-from" }, + (0, import_react42.createElement)("div", { className: "bt-section-title" }, title), + (0, import_react42.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), @@ -17104,7 +17115,7 @@ diff --git a/client.js b/client.js + ) : face ? null : room2.notice ? (0, import_react42.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react42.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), + face ? null : (0, import_react42.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), + face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react42.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react42.createElement)( - "div", ++ "div", { key: "members" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length })), - (0, import_react31.createElement)("div", { className: "bt-members", role: "list" }, members.map((member) => { @@ -17215,7 +17226,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 +18057,7 @@ +@@ -11457,7 +11457,7 @@ const bot = owner; const running = useSessionStatus((map) => map.get(part)?.running === true); const doing = useActivity((map) => map[bot?.id ?? sessionId]); @@ -17224,7 +17235,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 +18067,67 @@ +@@ -11467,41 +11467,67 @@ return false; }); const share = () => { @@ -17308,7 +17319,7 @@ diff --git a/client.js b/client.js ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +18141,12 @@ +@@ -11515,12 +11515,12 @@ const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -17325,7 +17336,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,98 +18161,97 @@ +@@ -11535,98 +11535,97 @@ setEditing(false); } } @@ -17469,7 +17480,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 +18299,11 @@ +@@ -11674,11 +11674,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); @@ -17486,7 +17497,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 +18332,7 @@ +@@ -11707,7 +11707,7 @@ trail.cancel(); }; }); @@ -17495,7 +17506,7 @@ diff --git a/client.js b/client.js const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +18346,13 @@ +@@ -11721,13 +11721,13 @@ observer.observe(box); return () => observer.disconnect(); }, []); @@ -17512,7 +17523,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -11737,13 +18362,13 @@ +@@ -11737,13 +11737,13 @@ title: bot.name, onClick: () => onView("details") }, @@ -17529,7 +17540,7 @@ diff --git a/client.js b/client.js "button", { key: tabId, -@@ -11764,9 +18389,9 @@ +@@ -11764,9 +11764,9 @@ } } }, @@ -17542,7 +17553,7 @@ diff --git a/client.js b/client.js className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +18403,20 @@ +@@ -11778,20 +11778,20 @@ }, "×") ); }), @@ -17570,7 +17581,7 @@ diff --git a/client.js b/client.js const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,43 +18442,43 @@ +@@ -11817,43 +11817,43 @@ window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -17627,7 +17638,7 @@ diff --git a/client.js b/client.js ref: input, type: "text", spellCheck: false, -@@ -11877,25 +18502,25 @@ +@@ -11877,25 +11877,25 @@ } }) ), @@ -17663,7 +17674,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +18532,41 @@ +@@ -11907,41 +11907,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); @@ -17721,7 +17732,7 @@ diff --git a/client.js b/client.js let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +18580,28 @@ +@@ -11955,28 +11955,28 @@ window.removeEventListener("resize", onResize); }; }, []); @@ -17757,7 +17768,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 +18638,7 @@ +@@ -12013,7 +12013,7 @@ return actions[name](...args); }; } @@ -17766,7 +17777,7 @@ diff --git a/client.js b/client.js const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +18721,7 @@ +@@ -12096,7 +12096,7 @@ for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -17775,7 +17786,7 @@ diff --git a/client.js b/client.js "aside", { ref: root, -@@ -12113,15 +18738,15 @@ +@@ -12113,15 +12113,15 @@ if (event.target === event.currentTarget) setLanded(true); } }, @@ -17796,7 +17807,7 @@ diff --git a/client.js b/client.js sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12217,9 +18842,79 @@ +@@ -12217,9 +12217,79 @@ @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -17878,7 +17889,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 +18922,15 @@ +@@ -12227,15 +12227,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); @@ -17898,7 +17909,7 @@ diff --git a/client.js b/client.js if (open) { setKept(open); return void 0; -@@ -12245,19 +18940,26 @@ +@@ -12245,19 +12245,26 @@ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -17935,7 +17946,7 @@ diff --git a/client.js b/client.js ); } -@@ -12294,19 +18996,19 @@ +@@ -12294,19 +12294,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 || ""}`); }; @@ -17962,7 +17973,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 +19042,8 @@ +@@ -12340,8 +12340,8 @@ items.push(item); return { item, number: items.length, secret }; }; @@ -17973,7 +17984,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 +19074,7 @@ +@@ -12372,7 +12372,7 @@ const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -17982,7 +17993,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +19100,11 @@ +@@ -12398,11 +12398,11 @@ let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -17996,7 +18007,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12410,18 +19112,18 @@ +@@ -12410,18 +12410,18 @@ for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; @@ -18020,7 +18031,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 +19182,9 @@ +@@ -12480,9 +12480,9 @@ if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); @@ -18033,7 +18044,7 @@ diff --git a/client.js b/client.js } if (node.nodeType !== 1) return ""; const el = node; -@@ -12552,7 +19254,7 @@ +@@ -12552,7 +12552,7 @@ } if (tag === "blockquote") { const quoted = block(body2); @@ -18042,7 +18053,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 +19272,12 @@ +@@ -12570,12 +12570,12 @@ return marked; } const body = document.body; @@ -18059,7 +18070,7 @@ diff --git a/client.js b/client.js stop ||= `the text passed ${limits.chars} characters`; } const page = { -@@ -12585,7 +19287,7 @@ +@@ -12585,7 +12585,7 @@ description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", @@ -18068,7 +18079,7 @@ diff --git a/client.js b/client.js items, stats: { ...stats, ms: Math.round(performance.now() - started) } }; -@@ -12613,15 +19315,15 @@ +@@ -12613,15 +12613,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) { @@ -18092,7 +18103,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 +19754,509 @@ +@@ -13052,11 +13052,658 @@ }; } @@ -18226,6 +18237,21 @@ diff --git a/client.js b/client.js +.bt-project-state{font-size:11px;margin-left:auto} +.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} +.bt-project-reason{font-size:11px;opacity:.7} ++ ++/* #1749: management panels. Same surface as the board, one form row per control, and a status ++ line that quotes the host's own answer — a refused save reads where the reader pressed it. */ ++.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} ++.bt-project-panel h3{margin:0} ++.bt-project-field{display:flex;flex-direction:column;gap:4px} ++.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} ++.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} ++.bt-project-field textarea{min-height:88px;resize:vertical} ++.bt-project-row-actions{display:flex;gap:8px;align-items:center} ++.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} ++.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} ++.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} ++.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} ++.bt-project-note{font-size:12px;opacity:.65;margin:0} +`; +var PROJECT_SOURCES = ["bot", "group", "workflow"]; +var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; @@ -18461,7 +18487,7 @@ diff --git a/client.js b/client.js + meetings.pending.length === 0 ? null : pendingRows + ); +} -+function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { ++function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen, onSettings, onMembers }) { + const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }); + return (0, import_react46.createElement)( + "div", @@ -18476,23 +18502,155 @@ diff --git a/client.js b/client.js + (0, import_react46.createElement)("span", null, [t("Blocked"), ": ", (0, import_react46.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), + (0, import_react46.createElement)("span", null, [t("Done"), ": ", (0, import_react46.createElement)("b", { key: "d" }, countText(view.counts.done))]) + ), -+ // Management affordances are drawn only for the owner; the refusal itself is the host's. -+ view.canManage ? (0, import_react46.createElement)("div", { className: "bt-project-actions" }, (0, import_react46.createElement)("button", { type: "button" }, t("Project settings")), (0, import_react46.createElement)("button", { type: "button" }, t("Members"))) : null, ++ // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses ++ // every write a non-owner attempts, so the refusal is an answer the reader sees, not a ++ // control that silently disappeared. ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-actions" }, ++ (0, import_react46.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), ++ (0, import_react46.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) ++ ), + (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }), + (0, import_react46.createElement)(ProjectQueueGroups, { view, onOpen }), + (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) + ); +} ++function ProjectSettingsPanel({ project, call: request, onClose, onSaved } = {}) { ++ const save = request ?? call; ++ const [kind, setKind] = (0, import_react46.useState)("bot"); ++ const [memberId, setMemberId] = (0, import_react46.useState)(""); ++ const [note, setNote] = (0, import_react46.useState)(""); ++ const [assistantOff, setAssistantOff] = (0, import_react46.useState)(project?.assistant?.off === true); ++ const [status, setStatus] = (0, import_react46.useState)(""); ++ const id = text(project?.id); ++ const run = async (endpoint, payload, done) => { ++ try { ++ const result = await save(endpoint, payload); ++ setStatus(done); ++ onSaved?.(result); ++ return result; ++ } catch (error) { ++ setStatus(error?.message ?? String(error)); ++ return null; ++ } ++ }; ++ return (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-panel", "data-panel": "project-settings" }, ++ (0, import_react46.createElement)("h3", null, t("Project settings")), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-field" }, ++ (0, import_react46.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react46.createElement)( ++ "select", ++ { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) }, ++ (0, import_react46.createElement)("option", { value: "bot" }, t("Bot")), ++ (0, import_react46.createElement)("option", { value: "group" }, t("Group")) ++ ), ++ (0, import_react46.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }), ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) ++ ) ++ ), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-field" }, ++ (0, import_react46.createElement)("label", null, t("Project Assistant")), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react46.createElement)("label", null, (0, import_react46.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) ++ ) ++ ), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-field" }, ++ (0, import_react46.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")), ++ (0, import_react46.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-memory-save", onClick: () => run("project-memory-write", { id, kind: "note", text: text(note) }, t("Memory saved.")) }, t("Save memory")) ++ ) ++ ), ++ (0, import_react46.createElement)("div", { className: "bt-project-status-line", role: "status" }, status), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) ++ ) ++ ); ++} ++function ProjectMembersPanel({ project, rows, onClose } = {}) { ++ const owners = []; ++ const add = (id) => { ++ const clean = text(id); ++ if (clean !== "" && !owners.includes(clean)) owners.push(clean); ++ }; ++ add(project?.ownerUserId); ++ for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id); ++ const boardMembers = []; ++ for (const row of Array.isArray(rows) ? rows : []) { ++ const name = text(row?.assignee); ++ if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name); ++ } ++ return (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-panel", "data-panel": "project-members" }, ++ (0, import_react46.createElement)("h3", null, t("Members")), ++ (0, import_react46.createElement)( ++ "ul", ++ { className: "bt-project-members" }, ++ owners.map((owner) => (0, import_react46.createElement)( ++ "li", ++ { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner }, ++ (0, import_react46.createElement)("span", null, `@${owner}`), ++ (0, import_react46.createElement)("span", { className: "bt-project-member-kind" }, t("Owner")) ++ )), ++ boardMembers.map((name) => (0, import_react46.createElement)( ++ "li", ++ { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name }, ++ (0, import_react46.createElement)("span", null, `@${name}`), ++ (0, import_react46.createElement)("span", { className: "bt-project-member-kind" }, t("On the board")) ++ )) ++ ), ++ owners.length === 0 && boardMembers.length === 0 ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null, ++ (0, import_react46.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-row-actions" }, ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) ++ ) ++ ); ++} +function ProjectBoardOverlay({ actions, call: request } = {}) { + const read = request ?? call; + const [state, setState] = (0, import_react46.useState)(null); ++ const [panel, setPanel] = (0, import_react46.useState)(null); + const [hash, setHash] = (0, import_react46.useState)(() => typeof window === "undefined" ? "" : window.location.hash); + const close = (0, import_react46.useCallback)(() => { + if (typeof window !== "undefined") window.location.hash = ""; ++ setPanel(null); + setState(null); + }, []); + (0, import_react46.useEffect)(() => { + if (typeof window === "undefined") return void 0; ++ const onKey = (event) => { ++ if (event.key !== "Escape") return; ++ if (panel !== null) setPanel(null); ++ else close(); ++ }; ++ window.addEventListener("keydown", onKey); ++ return () => window.removeEventListener("keydown", onKey); ++ }, [panel, close]); ++ (0, import_react46.useEffect)(() => { ++ if (typeof window === "undefined") return void 0; + const sync = () => setHash(window.location.hash); + window.addEventListener("hashchange", sync); + sync(); @@ -18557,7 +18715,7 @@ diff --git a/client.js b/client.js + close(); + actions?.openSettings?.("projects"); + } }, t("Open Projects in Settings")) -+ ) : (0, import_react46.createElement)(ProjectBoardPane, { ++ ) : panel === "settings" ? (0, import_react46.createElement)(ProjectSettingsPanel, { project: state.project, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react46.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react46.createElement)(ProjectBoardPane, { + project: state.project, + rows: state.rows, + canManage: state.canManage, @@ -18565,6 +18723,8 @@ diff --git a/client.js b/client.js + decisions: state.decisions, + meetings: state.meetings, + queues: state.queues, ++ onSettings: () => setPanel("settings"), ++ onMembers: () => setPanel("members"), + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { @@ -18603,7 +18763,7 @@ diff --git a/client.js b/client.js function disposeAll(disposers, label) { for (const dispose of [...disposers].reverse()) { try { -@@ -13075,8 +20275,8 @@ +@@ -13075,8 +13075,8 @@ }; return { reconcile(wanted) { @@ -18614,7 +18774,7 @@ diff --git a/client.js b/client.js if (!wanted[group] || mounted[group].length > 0) continue; try { mounted[group] = install[group](); -@@ -13086,7 +20286,7 @@ +@@ -13086,7 +13086,7 @@ } }, dispose() { @@ -18623,7 +18783,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +20305,7 @@ +@@ -13105,7 +13105,7 @@ } // src/client/user-message.js @@ -18632,7 +18792,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 +20319,165 @@ +@@ -13119,87 +13119,165 @@ } return { text: texts2.join("\n"), attachments, rest }; } @@ -18668,7 +18828,8 @@ 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 }); @@ -18681,8 +18842,7 @@ 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; @@ -18832,7 +18992,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 +20494,115 @@ +@@ -13216,42 +13216,115 @@ `; // src/client/approval-notices.js @@ -18961,7 +19121,7 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +20625,13 @@ +@@ -13274,7 +13274,13 @@ } catch { seenAt = {}; } @@ -18976,7 +19136,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13308,6 +20665,8 @@ +@@ -13308,6 +13308,8 @@ return () => window.removeEventListener("pagehide", onPageHide); }, "ds-bot: remember visits on page hide"); const activity = createSource({}); @@ -18985,7 +19145,7 @@ diff --git a/client.js b/client.js const work = createSource({ bots: {}, approvals: [] }); let workKey = ""; const live = createSource({}); -@@ -13344,13 +20703,25 @@ +@@ -13344,13 +13344,25 @@ } return { ...state, unread }; }); @@ -19012,7 +19172,7 @@ diff --git a/client.js b/client.js if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); -@@ -13358,6 +20729,11 @@ +@@ -13358,6 +13358,11 @@ activityKey = nextActivity; activity.set(value.activity ?? {}); } @@ -19024,7 +19184,7 @@ diff --git a/client.js b/client.js const nextWork = JSON.stringify(value.work ?? null); if (nextWork !== workKey) { workKey = nextWork; -@@ -13368,10 +20744,15 @@ +@@ -13368,10 +13368,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; @@ -19040,7 +19200,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 +20869,12 @@ +@@ -13488,20 +13488,12 @@ const style = installStyles("", "mentions"); let last = ""; const render = () => { @@ -19063,7 +19223,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +20893,7 @@ +@@ -13520,7 +13520,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; @@ -19072,7 +19232,7 @@ diff --git a/client.js b/client.js if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +20909,7 @@ +@@ -13536,7 +13536,7 @@ }; render(); const offRoster = roster.subscribe(render); @@ -19081,7 +19241,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +20961,7 @@ +@@ -13588,6 +13588,7 @@ // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -19089,7 +19249,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 +21027,12 @@ +@@ -13653,12 +13653,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. @@ -19105,7 +19265,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +21082,15 @@ +@@ -13708,10 +13708,15 @@ }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -19123,7 +19283,7 @@ diff --git a/client.js b/client.js }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); -@@ -13719,6 +21098,19 @@ +@@ -13719,6 +13719,19 @@ closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -19143,7 +19303,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 +21120,7 @@ +@@ -13728,7 +13728,7 @@ if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -19152,7 +19312,7 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); -@@ -13757,6 +21149,12 @@ +@@ -13757,6 +13757,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() } } })); }, @@ -19165,7 +19325,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13780,7 +21178,7 @@ +@@ -13780,7 +13780,7 @@ } return { ...state, reactions }; }), @@ -19174,7 +19334,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 +21206,16 @@ +@@ -13808,10 +13808,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 })), @@ -19192,7 +19352,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 +21223,31 @@ +@@ -13819,15 +13819,31 @@ secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -19225,7 +19385,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 +21256,7 @@ +@@ -13836,7 +13836,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 }), @@ -19234,7 +19394,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 +21266,8 @@ +@@ -13846,6 +13846,8 @@ noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -19243,7 +19403,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 +21312,7 @@ +@@ -13890,6 +13890,7 @@ actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -19251,7 +19411,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 +21345,9 @@ +@@ -13922,6 +13922,9 @@ wakeUpdate(); }; actions.repoStars = () => call("repo-stars"); @@ -19261,7 +19421,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 +21390,13 @@ +@@ -13964,7 +13964,13 @@ }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -19276,7 +19436,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +21421,17 @@ +@@ -13989,7 +13989,17 @@ } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -19294,7 +19454,7 @@ diff --git a/client.js b/client.js const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { -@@ -14001,7 +21443,12 @@ +@@ -14001,7 +14001,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 }; @@ -19307,7 +19467,7 @@ diff --git a/client.js b/client.js atMenu = null; for (const off of offs) off(); }; -@@ -14010,6 +21457,14 @@ +@@ -14010,6 +14010,14 @@ return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -19322,7 +19482,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 +21474,49 @@ +@@ -14019,33 +14019,49 @@ installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -19376,7 +19536,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 +21528,24 @@ +@@ -14057,15 +14057,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), @@ -19404,7 +19564,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,9 +21554,18 @@ +@@ -14074,9 +14074,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), diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 7f31a6f6a..936d0a411 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -42,6 +42,21 @@ export const PROJECT_BOARD_CSS = ` .bt-project-state{font-size:11px;margin-left:auto} .bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} .bt-project-reason{font-size:11px;opacity:.7} + +/* #1749: management panels. Same surface as the board, one form row per control, and a status + line that quotes the host's own answer — a refused save reads where the reader pressed it. */ +.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px} +.bt-project-panel h3{margin:0} +.bt-project-field{display:flex;flex-direction:column;gap:4px} +.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit} +.bt-project-field textarea{min-height:88px;resize:vertical} +.bt-project-row-actions{display:flex;gap:8px;align-items:center} +.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px} +.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))} +.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px} +.bt-project-note{font-size:12px;opacity:.65;margin:0} ` // Sources in the order the board reads them; a source with no rows is left out so three empty @@ -300,7 +315,7 @@ function ProjectBoardRecord({ record }) { meetings.pending.length === 0 ? null : pendingRows) } -export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { +export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen, onSettings, onMembers }) { const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), @@ -310,26 +325,121 @@ export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, m h('span', null, [t('In flight'), ': ', h('b', { key: 'f' }, countText(view.counts.inflight))]), h('span', null, [t('Blocked'), ': ', h('b', { key: 'b' }, countText(view.counts.blocked))]), h('span', null, [t('Done'), ': ', h('b', { key: 'd' }, countText(view.counts.done))])), - // Management affordances are drawn only for the owner; the refusal itself is the host's. - view.canManage ? h('div', { className: 'bt-project-actions' }, h('button', { type: 'button' }, t('Project settings')), h('button', { type: 'button' }, t('Members'))) : null, + // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses + // every write a non-owner attempts, so the refusal is an answer the reader sees, not a + // control that silently disappeared. + h('div', { className: 'bt-project-actions' }, + h('button', { type: 'button', 'data-action': 'project-settings', onClick: () => onSettings?.() }, t('Project settings')), + h('button', { type: 'button', 'data-action': 'project-members', onClick: () => onMembers?.() }, t('Members'))), h(ProjectBoardRows, { view, onOpen }), h(ProjectQueueGroups, { view, onOpen }), h(ProjectBoardRecord, { record: view.record })) } +// #1749: the settings panel. It writes through the three paths the host already had — bind or +// unbind a member (`project-bind`), the assistant's per-owner controls +// (`project-assistant-owner`) and the assistant's memory (`project-memory-write`) — and adds no +// fourth. Fields the host has no write path for are not drawn, so every control here can save. +export function ProjectSettingsPanel({ project, call: request, onClose, onSaved } = {}) { + const save = request ?? call + const [kind, setKind] = useState('bot') + const [memberId, setMemberId] = useState('') + const [note, setNote] = useState('') + const [assistantOff, setAssistantOff] = useState(project?.assistant?.off === true) + const [status, setStatus] = useState('') + const id = text(project?.id) + const run = async (endpoint, payload, done) => { + try { + const result = await save(endpoint, payload) + setStatus(done) + onSaved?.(result) + return result + } catch (error) { + // The host's refusal is the answer the reader acted for; quote it instead of guessing. + setStatus(error?.message ?? String(error)) + return null + } + } + return h('div', { className: 'bt-project-panel', 'data-panel': 'project-settings' }, + h('h3', null, t('Project settings')), + h('div', { className: 'bt-project-field' }, + h('label', { htmlFor: 'bt-project-member-id' }, t('Bind a member to this Project')), + h('div', { className: 'bt-project-row-actions' }, + h('select', { 'aria-label': t('Member kind'), value: kind, onChange: event => setKind(event.target.value) }, + h('option', { value: 'bot' }, t('Bot')), + h('option', { value: 'group' }, t('Group'))), + h('input', { id: 'bt-project-member-id', value: memberId, placeholder: t('Member id'), onChange: event => setMemberId(event.target.value) }), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-bind', onClick: () => run('project-bind', { kind, id: text(memberId), projectId: id }, t('Member bound.')) }, t('Bind')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-unbind', onClick: () => run('project-bind', { kind, id: text(memberId), projectId: null }, t('Member unbound.')) }, t('Unbind')))), + h('div', { className: 'bt-project-field' }, + h('label', null, t('Project Assistant')), + h('div', { className: 'bt-project-row-actions' }, + h('label', null, h('input', { type: 'checkbox', 'data-action': 'assistant-off', checked: assistantOff, onChange: event => setAssistantOff(event.target.checked) }), ' ', t('Pause the daily digest')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-assistant-save', onClick: () => run('project-assistant-owner', { id, settings: { off: assistantOff === true } }, t('Assistant settings saved.')) }, t('Save')))), + h('div', { className: 'bt-project-field' }, + h('label', { htmlFor: 'bt-project-note' }, t('Add to the assistant memory')), + h('textarea', { id: 'bt-project-note', value: note, onChange: event => setNote(event.target.value) }), + h('div', { className: 'bt-project-row-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'data-action': 'project-memory-save', onClick: () => run('project-memory-write', { id, kind: 'note', text: text(note) }, t('Memory saved.')) }, t('Save memory')))), + h('div', { className: 'bt-project-status-line', role: 'status' }, status), + h('div', { className: 'bt-project-row-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board')))) +} + +// #1749: who is on this Project, read from the record and the board — never a second member read. +// The host keeps no membership set beyond a Project's owners and the members its board rows +// already name, so this panel is read-only and says so instead of offering a control it cannot save. +export function ProjectMembersPanel({ project, rows, onClose } = {}) { + const owners = [] + const add = (id) => { const clean = text(id); if (clean !== '' && !owners.includes(clean)) owners.push(clean) } + add(project?.ownerUserId) + for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id) + const boardMembers = [] + for (const row of Array.isArray(rows) ? rows : []) { + const name = text(row?.assignee) + if (name !== '' && !boardMembers.includes(name)) boardMembers.push(name) + } + return h('div', { className: 'bt-project-panel', 'data-panel': 'project-members' }, + h('h3', null, t('Members')), + h('ul', { className: 'bt-project-members' }, + owners.map(owner => h('li', { key: `owner:${owner}`, className: 'bt-project-member', 'data-member-role': 'owner', 'data-member-id': owner }, + h('span', null, `@${owner}`), + h('span', { className: 'bt-project-member-kind' }, t('Owner')))), + boardMembers.map(name => h('li', { key: `board:${name}`, className: 'bt-project-member', 'data-member-role': 'board', 'data-member-id': name }, + h('span', null, `@${name}`), + h('span', { className: 'bt-project-member-kind' }, t('On the board'))))), + owners.length === 0 && boardMembers.length === 0 ? h('div', { className: 'bt-project-empty' }, t('No member is named yet.')) : null, + h('p', { className: 'bt-project-note' }, t('Member management is not part of this slice; owners and the members their cards name are listed here.')), + h('div', { className: 'bt-project-row-actions' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board')))) +} + // 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, 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) + const [panel, setPanel] = 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. const [hash, setHash] = useState(() => (typeof window === 'undefined' ? '' : window.location.hash)) const close = useCallback(() => { if (typeof window !== 'undefined') window.location.hash = '' + setPanel(null) setState(null) }, []) + // Escape leaves the panel first, then the board — the same two steps the kanban dialog offers. + useEffect(() => { + if (typeof window === 'undefined') return undefined + const onKey = (event) => { + if (event.key !== 'Escape') return + if (panel !== null) setPanel(null) + else close() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [panel, close]) useEffect(() => { if (typeof window === 'undefined') return undefined const sync = () => setHash(window.location.hash) @@ -380,9 +490,13 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { 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. + : panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, call: read, onClose: () => setPanel(null) }) + : panel === 'members' ? h(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, decisions: state.decisions, meetings: state.meetings, queues: state.queues, + onSettings: () => setPanel('settings'), + onMembers: () => setPanel('members'), // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and // lands on that Bot or group. Nothing here re-decides which board that is. onOpen: (board) => { diff --git a/patches/ds-bot/files/test/project-board-panels.test.js b/patches/ds-bot/files/test/project-board-panels.test.js new file mode 100644 index 000000000..12bce8246 --- /dev/null +++ b/patches/ds-bot/files/test/project-board-panels.test.js @@ -0,0 +1,123 @@ +// Project board's two management buttons (#1749). The board drew "Project settings" and "Members" +// with no handler at all, so clicking either only moved focus. These pin the three surfaces a +// reader now gets: each button opens its panel, the settings panel saves only through the write +// paths the host already has, and the members panel lists who the record and the board name. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/project-board.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], +}) +// A hook runtime: every panel is a plain function, so a slot array that survives a re-render is +// enough to type into a field, press a button, and read the tree it draws next. +let slots = [] +let index = 0 +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: () => { const at = index++; if (!(at in slots)) slots[at] = true }, + 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 module = { exports: {} } +runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, Date, Promise, console, + window: { location: { hash: '' }, addEventListener() {}, removeEventListener() {} }, + require: (name) => { + if (name === 'react') return react + throw new Error(`no platform module ${name}`) + }, +}) +const { ProjectBoardPane, ProjectSettingsPanel, ProjectMembersPanel } = module.exports + +const render = (component, props) => { index = 0; return component(props) } +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node === null || typeof node !== 'object') return out + if (typeof node.type === 'function') return all(node.type(node.props), out) + out.push(node) + all(node.children, out) + return out +} +const byAction = (tree, action) => all(tree).find(node => node.props?.['data-action'] === action) +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.children) + return '' +} +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +const rows = [{ id: 'task-1', source: 'bot', column: 'in-progress', assignee: 'Dev4' }] + +test('both management buttons open their panel, owner or not', () => { + const opened = [] + const tree = render(ProjectBoardPane, { + project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, + onSettings: () => opened.push('settings'), onMembers: () => opened.push('members'), + }) + const settings = byAction(tree, 'project-settings') + const members = byAction(tree, 'project-members') + assert.ok(settings, 'Project settings is drawn for a non-owner too — the host refuses, not the page') + assert.ok(members, 'Members is drawn for a non-owner too') + settings.props.onClick() + members.props.onClick() + assert.deepEqual(opened, ['settings', 'members']) +}) + +test('the settings panel saves through project-bind, project-assistant-owner and project-memory-write', async () => { + const sent = [] + const save = async (endpoint, payload) => { sent.push({ endpoint, payload }); return { ok: true } } + let tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + const memberInput = all(tree).find(node => node.props?.id === 'bt-project-member-id') + memberInput.props.onChange({ target: { value: 'bot-9' } }) + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + byAction(tree, 'project-bind').props.onClick() + await tick() + assert.deepEqual(JSON.parse(JSON.stringify(sent.at(-1))), { endpoint: 'project-bind', payload: { kind: 'bot', id: 'bot-9', projectId: 'project-1' } }) + + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + byAction(tree, 'project-assistant-save').props.onClick() + await tick() + assert.deepEqual(JSON.parse(JSON.stringify(sent.at(-1))), { endpoint: 'project-assistant-owner', payload: { id: 'project-1', settings: { off: false } } }) + + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + const note = all(tree).find(node => node.props?.id === 'bt-project-note') + note.props.onChange({ target: { value: 'ship on Friday' } }) + tree = render(ProjectSettingsPanel, { project: { id: 'project-1', name: 'FlyBot' }, call: save, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await tick() + assert.deepEqual(JSON.parse(JSON.stringify(sent.at(-1))), { endpoint: 'project-memory-write', payload: { id: 'project-1', kind: 'note', text: 'ship on Friday' } }) +}) + +test("the settings panel shows the host's refusal instead of pretending the save landed", async () => { + const refused = Object.assign(new Error('Only the owner of this Project may change it'), { code: 'bot/forbidden' }) + const save = async () => { throw refused } + let tree = render(ProjectSettingsPanel, { project: { id: 'project-1' }, call: save, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await tick() + tree = render(ProjectSettingsPanel, { project: { id: 'project-1' }, call: save, onClose() {} }) + const status = all(tree).find(node => node.props?.role === 'status') + assert.match(text(status), /Only the owner of this Project/) +}) + +test('the members panel lists the owners and the members the board names, and offers no write', () => { + const tree = render(ProjectMembersPanel, { + project: { id: 'project-1', ownerUserId: 'owner-1', ownerUserIds: ['owner-1', 'owner-2'] }, + rows, onClose() {}, + }) + const members = all(tree).filter(node => node.props?.['data-member-role'] !== undefined) + assert.deepEqual(members.map(node => node.props['data-member-id']), ['owner-1', 'owner-2', 'Dev4']) + assert.deepEqual(members.map(node => node.props['data-member-role']), ['owner', 'owner', 'board']) + assert.equal(byAction(tree, 'project-bind'), undefined, 'membership management is a later slice, not a dead button here') +}) 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 c5b38709d..f6d54d165 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -103,7 +103,7 @@ test('a Project with nothing under it is an empty state, not an error', () => { assert.equal(missing.canManage, false, 'no canManage answer is read as read-only') }) -test('the pane draws the grouped rows and only the owner sees the management controls', () => { +test('the pane draws the grouped rows and both management buttons, owner or not (#1749)', () => { const owner = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: true })) const member = all(ProjectBoardPane({ project: { id: 'p', name: 'FlyBot' }, rows, canManage: false })) assert.match(textOf(owner[0]), /FlyBot/) @@ -114,7 +114,8 @@ test('the pane draws the grouped rows and only the owner sees the management con assert.equal(owner.some(node => textOf(node) === 'Project settings'), true) const memberRows = member.filter(node => node.props?.['data-task-id'] !== undefined) assert.deepEqual(memberRows.map(node => node.props['data-task-id']), ['task-b1', 'task-g1', 'task-w1'], 'a member still reads the board') - assert.equal(member.some(node => textOf(node) === 'Project settings'), false) + // #1749: a non-owner sees the same two buttons; the host refuses the write and the panel says so. + assert.equal(member.some(node => textOf(node) === 'Project settings'), true) }) test('an empty Project renders the empty text and no row', () => {