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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1854-projects-sidebar.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expanding **Projects** in the left sidebar now lists the Projects you can see, each with the role you hold there, and a right-click can switch that role: the sidebar had been asking the host for an endpoint that does not exist, so the list was empty on every deployment. The duplicate **Project board** button is gone from the Tasks dialog, so a Project opens from the sidebar only ([#1854](https://github.com/ThinkFlowLab/FlyBot/issues/1854)).
83 changes: 36 additions & 47 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7129,12 +7129,12 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]);
function callTree(root) {
const calls = [];
const seen = /* @__PURE__ */ new Set();
const visit = (call5) => {
const id = call5.callId ?? call5;
const visit = (call4) => {
const id = call4.callId ?? call4;
if (seen.has(id)) return;
seen.add(id);
calls.push(call5);
for (const child of call5.subCalls ?? []) visit(child);
calls.push(call4);
for (const child of call4.subCalls ?? []) visit(child);
};
visit(root);
return calls;
Expand All @@ -7148,20 +7148,20 @@ function useShownCards(actions, sessionId, callIds) {
return () => actions.noteCards(sessionId, ids, -1);
}, [sessionId, key]);
}
function renderCall(call5, { sessionId, roster, actions, useUi, findBot }) {
const tool = toolName(call5);
const done = call5.kind === "tool-result";
const { text: arg, value: argValue } = toolArgs(call5);
function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) {
const tool = toolName(call4);
const done = call4.kind === "tool-result";
const { text: arg, value: argValue } = toolArgs(call4);
const self = roster.byId[sessionId];
switch (tool) {
case "message_bot": {
const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" };
if (done && call5.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name }));
if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name }));
return (0, import_react9.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true });
}
case "stop_bot": {
const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" };
if (done && call5.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name }));
if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name }));
return (0, import_react9.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true });
}
case "create_bot": {
Expand All @@ -7174,7 +7174,7 @@ function renderCall(call5, { sessionId, roster, actions, useUi, findBot }) {
return done && target ? (0, import_react9.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null;
}
case "group_relay":
return (0, import_react9.createElement)(GroupReplies, { sessionId, callId: call5.callId, done, content: call5.content, roster, actions, useUi });
return (0, import_react9.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi });
case "create_group":
case "update_group":
case "delete_group":
Expand All @@ -7186,25 +7186,25 @@ function renderCall(call5, { sessionId, roster, actions, useUi, findBot }) {
post_to_group: ["Posted in", "Couldn't post in the group"]
}[tool];
if (!done) return null;
const result = contentText(call5.content);
const result = contentText(call4.content);
const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result);
if (call5.isError || !named) return (0, import_react9.createElement)("div", { className: "bt-event", title: result }, t(verbs[1]));
if (call4.isError || !named) return (0, import_react9.createElement)("div", { className: "bt-event", title: result }, t(verbs[1]));
const name = named[2];
const room2 = roster.rooms.find((entry) => entry.name === name);
return (0, import_react9.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions });
}
case "ask_user": {
const question = roster.questions?.[sessionId];
if (!question || question.callId !== call5.callId) return (0, import_react9.createElement)("span", { className: "bt-q-gone", hidden: true });
if (!question || question.callId !== call4.callId) return (0, import_react9.createElement)("span", { className: "bt-q-gone", hidden: true });
return (0, import_react9.createElement)(PendingQuestion, { sessionId, question, actions, inline: true });
}
case "request_secret":
return (0, import_react9.createElement)(SecretToolCell, { sessionId, callId: call5.callId, name: arg("name"), done, content: call5.content, roster, actions });
return (0, import_react9.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions });
case "ask_user_question": {
if (!done) return null;
let parsed = null;
try {
parsed = JSON.parse(contentText(call5.content));
parsed = JSON.parse(contentText(call4.content));
} catch {
parsed = null;
}
Expand All @@ -7220,7 +7220,7 @@ function renderCall(call5, { sessionId, roster, actions, useUi, findBot }) {
}
case "remember":
case "forget":
return done && !call5.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call5.content), roster, actions, findBot }) : null;
return done && !call4.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null;
default:
return null;
}
Expand All @@ -7232,14 +7232,14 @@ function ToolCell({ node, sessionId, useRoster, useUi, actions }) {
const hidden = isHiddenTurn(roster, sessionId, turn);
const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId);
const calls = hidden ? [] : callTree(root);
const drawn = calls.filter((call5) => DRAWN.has(toolName(call5)));
const running = root.kind === "tool-result" ? void 0 : calls.findLast((call5) => call5.kind !== "tool-result");
const drawn = calls.filter((call4) => DRAWN.has(toolName(call4)));
const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result");
const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null;
useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note);
useShownCards(actions, sessionId, drawn.filter((call5) => CARDS.has(toolName(call5)) && call5.callId).map((call5) => call5.callId));
useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId));
if (drawn.length === 0) return null;
const env = { sessionId, roster, actions, useUi, findBot };
return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call5, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call5.callId ?? index }, renderCall(call5, env))));
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
Expand Down Expand Up @@ -9036,7 +9036,6 @@ function KanbanDialog({ roster, actions, leaving = false }) {
if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id });
if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id });
}, []);
const projectId = scope?.kind === "bot" ? roster.byId?.[scope.botId]?.projectId : scope?.kind === "group" ? roster.roomsById?.[scope.roomId]?.projectId : void 0;
const counts2 = {};
for (const task of [...own, ...archive]) {
const id = task.ownerBotId;
Expand Down Expand Up @@ -9079,17 +9078,8 @@ function KanbanDialog({ roster, actions, leaving = false }) {
{ className: "bt-mem-head" },
(0, import_react15.createElement)("h2", { id: "bt-board-heading" }, t("Board")),
(0, import_react15.createElement)("span", { className: "bt-mem-status" }, name),
// #1701: the board the Bot or group belongs to opens from here; nothing else writes the
// Project hash, so without this the Project board was reachable only by editing the URL.
projectId ? (0, import_react15.createElement)("button", {
type: "button",
className: "bt-soft bt-soft-sm",
"data-open-project": projectId,
onClick: () => {
window.location.hash = `#project=${projectId}`;
close();
}
}, t("Project board")) : null,
// #1854: the left sidebar's Projects row is the one entry to a Project board; this header
// used to draw a second door (#1701), so the sidebar row was the only way in for the rest.
(0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }),
(0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon))
),
Expand Down Expand Up @@ -9178,7 +9168,6 @@ var KANBAN_CSS = `
// src/client/projects-nav.js
var import_react16 = require("react");
var import_react_dom3 = require("react-dom");
var call4 = (endpoint, payload) => call(`flybot/${endpoint}`, payload);
var PROJECTS_NAV_CSS = `
.bt-projects-row { display: flex; align-items: center; gap: 8px; }
.bt-projects-chevron { margin-left: auto; display: inline-flex; transition: transform 120ms ease; }
Expand Down Expand Up @@ -9308,7 +9297,7 @@ function ProjectRoleMenu({ project, x, y, onPick, onClose }) {
), document.body);
}
function ProjectsNav({ wide = true, request, onOpen } = {}) {
const read = request ?? call4;
const read = request ?? call;
const open = (0, import_react16.useCallback)((id) => {
if (onOpen) {
onOpen(id);
Expand Down Expand Up @@ -15098,7 +15087,7 @@ function settleWorkflowRead(value, fallback = "Could not read workflows") {
if (templates.length === 0 && workflows.length === 0) return { phase: "empty", error: "", pack };
return { phase: "ready", error: "", pack };
}
function readWorkflows(call5, { timeoutMs = READ_MS } = {}) {
function readWorkflows(call4, { timeoutMs = READ_MS } = {}) {
return new Promise((resolve) => {
let settled = false;
const finish = (value) => {
Expand All @@ -15108,7 +15097,7 @@ function readWorkflows(call5, { timeoutMs = READ_MS } = {}) {
resolve(value);
};
const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs);
Promise.resolve().then(() => call5("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null }));
Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null }));
});
}
function moveNode(nodes, id, x, y) {
Expand Down Expand Up @@ -15537,15 +15526,15 @@ function DeleteModal({ workflow, onClose, onConfirm }) {
)
);
}
function WorkflowsPage({ call: call5, roster }) {
function WorkflowsPage({ call: call4, roster }) {
const [phase, setPhase] = (0, import_react36.useState)("loading");
const [pack, setPack] = (0, import_react36.useState)(null);
const [error, setError] = (0, import_react36.useState)("");
const [opened, setOpened] = (0, import_react36.useState)(null);
const [editing, setEditing] = (0, import_react36.useState)(false);
const [renaming, setRenaming] = (0, import_react36.useState)(null);
const [deleting, setDeleting] = (0, import_react36.useState)(null);
const invoke = typeof call5 === "function" ? call5 : () => Promise.reject(new Error("Could not read workflows"));
const invoke = typeof call4 === "function" ? call4 : () => Promise.reject(new Error("Could not read workflows"));
const apply2 = (settled) => {
setPhase(settled.phase);
setPack(settled.pack);
Expand Down Expand Up @@ -17718,16 +17707,16 @@ var projectMembers = (roster) => [
...(roster?.bots ?? []).filter((bot) => !bot.remote).map((bot) => ({ kind: "bot", id: bot.id, name: bot.name, projectId: bot.projectId ?? null })),
...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null }))
];
function ProjectsList({ projects, roster, call: call5, reload }) {
function ProjectsList({ projects, roster, call: call4, reload }) {
const members = projectMembers(roster);
const bind = async (choice, project) => {
const [kind, id] = String(choice ?? "").split(":");
if (!kind || !id) return;
await call5("project-bind", { kind, id, projectId: project.id });
await call4("project-bind", { kind, id, projectId: project.id });
await reload?.();
};
const unbind = async (member) => {
await call5("project-bind", { kind: member.kind, id: member.id, projectId: null });
await call4("project-bind", { kind: member.kind, id: member.id, projectId: null });
await reload?.();
};
const bound2 = members.filter((member) => member.projectId !== null && member.projectId !== void 0);
Expand Down Expand Up @@ -17783,14 +17772,14 @@ function ProjectsList({ projects, roster, call: call5, reload }) {
)
];
}
function ProjectsPage({ roster, actions, run, call: call5 }) {
function ProjectsPage({ roster, actions, run, call: call4 }) {
const [state, setState] = (0, import_react41.useState)({ projects: [], error: null });
const [name, setName] = (0, import_react41.useState)("");
const [workDir, setWorkDir] = (0, import_react41.useState)(null);
const reload = (0, import_react41.useCallback)(async () => {
const listing = await call5("projects");
const listing = await call4("projects");
setState({ projects: listing?.projects ?? [], error: null });
}, [call5]);
}, [call4]);
(0, import_react41.useEffect)(() => {
void reload().catch((error) => setState({ projects: [], error: error?.message ?? String(error) }));
}, [reload]);
Expand All @@ -17800,7 +17789,7 @@ function ProjectsPage({ roster, actions, run, call: call5 }) {
const body = { name: trimmed3 };
if (workDir !== null && workDir !== "") body.workDir = workDir;
try {
await call5("project-create", body);
await call4("project-create", body);
setName("");
setWorkDir(null);
await reload();
Expand Down Expand Up @@ -17844,7 +17833,7 @@ function ProjectsPage({ roster, actions, run, call: call5 }) {
),
// The list is rendered here rather than as a child element: it holds no state of its own,
// and the page keeps one reload path for both halves.
...ProjectsList({ projects: state.projects, roster, call: call5, reload })
...ProjectsList({ projects: state.projects, roster, call: call4, reload })
];
}

Expand Down
12 changes: 2 additions & 10 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,6 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
if (kind === 'bot' && roster.byId?.[id]) setScope({ kind: 'bot', botId: id })
if (kind === 'group' && roster.roomsById?.[id]) setScope({ kind: 'group', roomId: id })
}, [])
// The Project this board belongs to, when its Bot or group is bound to one.
const projectId = scope?.kind === 'bot'
? roster.byId?.[scope.botId]?.projectId
: scope?.kind === 'group' ? roster.roomsById?.[scope.roomId]?.projectId : undefined
const counts = {}
for (const task of [...own, ...archive]) {
const id = task.ownerBotId
Expand Down Expand Up @@ -419,12 +415,8 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
h('div', { className: 'bt-mem-head' },
h('h2', { id: 'bt-board-heading' }, t('Board')),
h('span', { className: 'bt-mem-status' }, name),
// #1701: the board the Bot or group belongs to opens from here; nothing else writes the
// Project hash, so without this the Project board was reachable only by editing the URL.
projectId ? h('button', {
type: 'button', className: 'bt-soft bt-soft-sm', 'data-open-project': projectId,
onClick: () => { window.location.hash = `#project=${projectId}`; close() },
}, t('Project board')) : null,
// #1854: the left sidebar's Projects row is the one entry to a Project board; this header
// used to draw a second door (#1701), so the sidebar row was the only way in for the rest.
h('span', { className: 'bt-mem-head-gap' }),
h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))),
h(BoardView, {
Expand Down
5 changes: 3 additions & 2 deletions core/src/client/projects-nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { createPortal } from 'react-dom'
import { call as callHost } from './api.js'
import { t } from './i18n.js'

const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payload)
// #1854: `projects` and `project-role` are core endpoints, dispatched by bare name in
// `core/src/host/channel.js`; a `flybot/` prefix here names an endpoint the host does not have.

export const PROJECTS_NAV_CSS = `
.bt-projects-row { display: flex; align-items: center; gap: 8px; }
Expand Down Expand Up @@ -132,7 +133,7 @@ export function ProjectRoleMenu({ project, x, y, onPick, onClose }) {
}

export function ProjectsNav({ wide = true, request, onOpen } = {}) {
const read = request ?? call
const read = request ?? callHost
const open = useCallback((id) => {
if (onOpen) { onOpen(id); return }
if (typeof window !== 'undefined') window.location.hash = projectHash(id)
Expand Down
11 changes: 5 additions & 6 deletions core/test/project-board-entry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,19 +119,18 @@ test('#1701: a board row draws the title and opens the board that owns the task'
assert.deepEqual(plain(opened), [{ kind: 'bot', id: 'a' }], 'clicking opens the board that owns the task')
})

test('#1701: the Tasks dialog offers an entry that opens the Project board', async () => {
const { KanbanDialog, window: fake } = await load('kanban.js')
test('#1854: the Tasks dialog has no second entry to the Project board', async () => {
const { KanbanDialog } = await load('kanban.js')
states.length = 0
states.push(
{ kind: 'bot', botId: 'a' }, null, '', '',
[{ id: 'task-1', status: 'working', ownerBotId: 'a', title: 'One' }], [], [],
)
const tree = expand(KanbanDialog({ roster: roster(), actions: { closeBoard: () => {}, tasks: async () => [], openBoard: () => {} } }))
// #1854: Projects are entered from the left sidebar only. The board's own `Project board` button
// was the second door, so a Bot bound to a Project no longer draws one here.
const entry = all(tree).filter(node => typeof node.props?.['data-open-project'] === 'string')
assert.equal(entry.length, 1, 'a Bot bound to a Project gets one entry on the board')
assert.equal(entry[0].props['data-open-project'], 'project-rocket')
entry[0].props.onClick()
assert.equal(fake.location.hash, '#project=project-rocket', 'the entry opens the Project board through the hash')
assert.equal(entry.length, 0, 'the Tasks dialog no longer offers a Project board entry')
})

test('#1701: the three board columns have their own labels', async () => {
Expand Down
Loading
Loading