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
11 changes: 7 additions & 4 deletions servers/gateway/dashboard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import express from "express";
import { renderLayout, renderLogin, render2faVerify, render2faRecovery, render2faSetup, renderResetRequest, renderResetForm } from "./shared/layout.js";
import { playerBarHtml, playerBarJs } from "./shared/player.js";
import { headerIconsHtml, headerIconsJs, tamagotchiHtml, tamagotchiJs } from "./shared/notifications.js";
import perchHubRouter from "../routes/perch-hub.js";
import {
dashboardAuth,
isPasswordSet,
Expand Down Expand Up @@ -80,6 +79,7 @@ import onboardingPanel, { handleIdentityBackupPost, handleCloudProviderPost, han
import connectPanel from "./panels/connect.js";
import fediversePanel from "./panels/fediverse.js";
import meteringPanel from "./panels/metering.js";
import perchHubPanel from "./panels/perch-hub.js";
import { handleFixItAction } from "../fix-it/index.js";
import bundlesRouterFactory from "../routes/bundles.js";
import perchApiRouter from "../routes/perch.js";
Expand Down Expand Up @@ -114,6 +114,7 @@ export default function dashboardRouter(mcpAuthMiddleware) {
registerPanel(connectPanel);
registerPanel(fediversePanel);
registerPanel(meteringPanel);
registerPanel(perchHubPanel);

// Load third-party panels (async, non-blocking)
loadExternalPanels().catch((err) => {
Expand Down Expand Up @@ -713,9 +714,11 @@ export default function dashboardRouter(mcpAuthMiddleware) {
// Normal mount (session-cookie-authenticated path)
router.use("/dashboard", bundlesRouter);

router.use("/dashboard", perchHubRouter(dashboardAuth));
// Short link. Outside the /dashboard mount, so it carries no auth — it is a
// redirect with no content, and the destination is fully gated.
// Perch Hub itself is a registered panel now (panels/perch-hub.js),
// dispatched by the generic "/dashboard/:panelId" route below — no
// separate mount here. This is just the short link. Outside the
// /dashboard mount, so it carries no auth — it is a redirect with no
// content, and the destination is fully gated.
router.get("/perch", (req, res) => res.redirect(302, "/dashboard/perch"));

// SSO launch — authenticated on THIS instance (after dashboardAuth). Mints a
Expand Down
11 changes: 10 additions & 1 deletion servers/gateway/dashboard/panels/bot-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,21 @@ export default {
`title="Edit bot definition">${t("botboard.editBotLink", lang)}</a>`
: "";

// Mirrors Perch's own "Board" link back to here (perch-hub/html.js's
// <nav class="machines">) — unconditional, like that one, so the board
// is reachable from Perch and vice versa regardless of which bot (if
// any) is selected.
const perchLink =
`<a href="/dashboard/perch" ` +
`style="font-size:.78rem;color:var(--crow-text-muted);text-decoration:none;margin-left:.3rem">` +
`${t("botboard.perchLink", lang)}</a>`;

const switcher =
`<form method="GET" action="/dashboard/bot-board" class="bb-switch">` +
`<label for="bb-bot" style="font-size:.8rem;color:var(--crow-text-muted)">${t("botboard.labelBotSwitcher", lang)}</label>` +
`<select id="bb-bot" name="bot" onchange="this.form.requestSubmit ? this.form.requestSubmit() : this.form.submit()">` +
switcherOptions +
`</select>` + editBotLink +
`</select>` + editBotLink + perchLink +
`<noscript><button type="submit">Go</button></noscript>` +
switcherButtons +
`</form>`;
Expand Down
49 changes: 25 additions & 24 deletions servers/gateway/dashboard/panels/bot-board/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export function clientJs(botId, trackerType, projectId, trackerSlug, contextFiel
.then(function(r){return r.json().catch(function(){return {};}).then(function(j){return {ok:r.ok,status:r.status,j:j};});});
}
function reload(){ location.reload(); }
// Nav-gap fix: every session action that used to open the in-page bird
// drawer now navigates to the standalone Perch Hub instead — a full-page
// chat surface, not a cramped side panel. sid is optional: action=sessions
// has no specific session, so it goes to the bare hub with no fragment.
function goToPerch(sid){ location.href = sid ? '/dashboard/perch#'+encodeURIComponent(sid) : '/dashboard/perch'; }

var drawer=$('bb-drawer'), trackerDrawer=$('bb-tracker-drawer'), cur=null, dragId=null, dragType=null;
// Track 1: plans are RECORDS now (board_plans), not a file — no mtime
Expand Down Expand Up @@ -446,7 +451,7 @@ export function clientJs(botId, trackerType, projectId, trackerSlug, contextFiel
if(birdGlyph && ev.target.closest('.bb-card')){
ev.preventDefault();
ev.stopPropagation();
openBirdDrawer(birdGlyph.getAttribute('data-bird-sid'));
goToPerch(birdGlyph.getAttribute('data-bird-sid'));
return;
}
var c=ev.target.closest && ev.target.closest('.bb-card');
Expand Down Expand Up @@ -876,13 +881,13 @@ export function clientJs(botId, trackerType, projectId, trackerSlug, contextFiel
parseFilterHash();
applyFilters();

// Track 3 Task 13: hash-driven drawer open — #bird=<sid> opens straight
// to that session, #card=<id> scrolls the card into view and opens its
// live bird's drawer if one is on it. Runs AFTER parseFilterHash so a
// drawer link co-existing with a search/status hash is never lost.
// Track 3 Task 13: hash-driven open — #bird=<sid> navigates straight to
// that session in Perch, #card=<id> scrolls the card into view and opens
// its live bird's drawer if one is on it. Runs AFTER parseFilterHash so
// a link co-existing with a search/status hash is never lost.
if(window._bbForeignHash){
if(window._bbForeignHash.bird && typeof openBirdDrawer==='function'){
openBirdDrawer(window._bbForeignHash.bird);
if(window._bbForeignHash.bird){
goToPerch(window._bbForeignHash.bird);
} else if(window._bbForeignHash.card && typeof bdFocusCard==='function'){
bdFocusCard(window._bbForeignHash.card);
}
Expand Down Expand Up @@ -1235,15 +1240,12 @@ export function clientJs(botId, trackerType, projectId, trackerSlug, contextFiel
if(sendBtn) sendBtn.disabled=true;
perchApi('POST','/bots/'+encodeURIComponent(roostDispatchBotId)+'/dispatch',{card_id:Number(cardId),note:note}).then(function(r){
if(r.ok){
// Fix round 1: a successful dispatch used to close the dialog and
// call the (still-stub) openBirdDrawer — invisible, indistinguishable
// from a dropped click. The strip/card-face bird can't be patched
// into existence client-side (a bird-less card face carries no
// .bb-bird span to patch — see the bird-state handler below), so the
// honest fix is: show a perceivable success line, THEN reload. The
// reloaded SSR renders the bird on the strip + card truthfully.
// Nav-gap fix: a successful dispatch shows the perceivable success
// line, then navigates to the new session in Perch — POST
// /bots/:id/dispatch responds with the spawned session's id
// (perch-interactive-api.js's result object, which carries sessionId).
msg($('bb-rd-msg'),'${tJs("botboard.roostDispatchSent", lang)}','ok');
setTimeout(reload,600);
goToPerch(r.j&&r.j.sessionId);
} else if(r.status===409 && r.j && r.j.error==='card_occupied'){
// A raced dispatch — surfaced as the dialog's OWN error line, not a
// toast: the picker is still open and the operator needs to pick a
Expand Down Expand Up @@ -1291,17 +1293,16 @@ export function clientJs(botId, trackerType, projectId, trackerSlug, contextFiel
var botId=actBtn.getAttribute('data-bot');
var sid=actBtn.getAttribute('data-sid');
if(action==='dispatch'){ openRoostDispatch(botId); return; }
// The DOM already carries the bird's display name (.bb-roost-name,
// rendered by roostBirdHtml) — read it here rather than a fresh API
// round trip just to fill in the drawer header.
var birdWrap=actBtn.closest&&actBtn.closest('.bb-roost-bird');
var nameEl=birdWrap&&birdWrap.querySelector('.bb-roost-name');
var botName=nameEl?nameEl.textContent:null;
if(action==='open' || action==='answer'){ openBirdDrawer(sid,botId,botName); return; }
if(action==='sessions'){ openBirdDrawer(sid||null,botId,botName); return; }
// Nav-gap fix: these used to open the in-page bird drawer — they now
// navigate to Perch, so the bird's display name (only ever needed for
// the drawer's own header) is no longer read here.
if(action==='open' || action==='answer'){ goToPerch(sid); return; }
// action='sessions' has no specific session to jump to — it goes to
// the bare hub, not a session fragment.
if(action==='sessions'){ goToPerch(); return; }
if(action==='talk'){
perchApi('POST','/bots/'+encodeURIComponent(botId)+'/interactive').then(function(r){
if(r.ok){ openBirdDrawer(r.j&&r.j.sessionId,botId,botName); }
if(r.ok){ goToPerch(r.j&&r.j.sessionId); }
else { crowToast((r.j&&r.j.error)||'${tJs("botboard.roostActionFailed", lang)}', {type:'error'}); }
}).catch(function(){ crowToast('${tJs("botboard.roostActionFailed", lang)}', {type:'error'}); });
return;
Expand Down
32 changes: 32 additions & 0 deletions servers/gateway/dashboard/panels/perch-hub.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Perch Hub Panel — bot chat surface, registered so it gets a nav entry and
* a launcher icon like every other panel (it did not, before this file —
* it was a bare route mounted straight onto /dashboard from index.js).
*
* Renders its OWN standalone document (perchHubDocument), never the
* dashboard shell: dashboard/index.js's panel dispatcher only sends
* `result` when `!res.headersSent` ("Handler may have already sent
* response"), so a panel handler is free to write its own response, and
* Perch relies on that — the panel shell is what made the old drawer
* cramped on a phone. This handler must never call layout().
*/

import { perchHubDocument } from "../perch-hub/html.js";
import { engineStatus } from "../../bot-engine-status.js";

export default {
id: "perch",
name: "Perch Hub",
icon: "messages",
route: "/dashboard/perch",
navOrder: 15.5,
category: "ai",

async handler(req, res, { lang }) {
// engineStatus() is a LEAF module of synchronous fs stats
// (bot-engine-status.js:84) — safe and cheap from a route. /roost cannot
// report engine state, so without this the list looks normal and the
// first tap fails with a raw error.
res.type("html").send(perchHubDocument(lang, engineStatus()));
},
};
3 changes: 2 additions & 1 deletion servers/gateway/dashboard/shared/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -1296,6 +1296,7 @@ export const translations = {
"botboard.noBotsOption": { en: "-- no bots --", es: "-- sin bots --" },
"botboard.labelBotSwitcher": { en: "Bot", es: "Bot" },
"botboard.editBotLink": { en: "Edit bot", es: "Editar bot" },
"botboard.perchLink": { en: "Open Perch chat", es: "Abrir chat de Perch" },
"botboard.botNotFound": { en: "not found or disabled.", es: "no encontrado o desactivado." },
"botboard.colBot": { en: "Bot", es: "Bot" },
"botboard.colInstance": { en: "Instance", es: "Instancia" },
Expand Down Expand Up @@ -1397,7 +1398,7 @@ export const translations = {
"botboard.roostDispatchNoteLabel": { en: "Note (optional)", es: "Nota (opcional)" },
"botboard.roostDispatchNoCards": { en: "No free cards to send this bot to.", es: "No hay tarjetas libres para enviar a este bot." },
"botboard.roostDispatchConfirm": { en: "Send", es: "Enviar" },
"botboard.roostDispatchSent": { en: "Sent — reloading…", es: "Enviado — recargando…" },
"botboard.roostDispatchSent": { en: "Sent — opening…", es: "Enviado — abriendo…" },
"botboard.roostDispatchOccupied": { en: "That card was just claimed by another session.", es: "Esa tarjeta acaba de ser tomada por otra sesi\u00f3n." },
"botboard.roostConfirmRecall": { en: "Recall this session? This stops it.", es: "\u00bfRetirar esta sesi\u00f3n? Esto la detiene." },
"botboard.roostActionFailed": { en: "Action failed.", es: "La acci\u00f3n fall\u00f3." },
Expand Down
29 changes: 0 additions & 29 deletions servers/gateway/routes/perch-hub.js

This file was deleted.

74 changes: 74 additions & 0 deletions tests/bot-board-perch-link.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Nav-gap fix, Task B — the bot board must link BACK to Perch. Perch already
* links to the board (perch-hub/html.js's <nav class="machines">); before
* this fix the reverse link did not exist, so an operator on the board had
* no way back to the chat surface.
*
* Harness mirrors tests/roost-strip-ui.test.js: a hand-rolled scratch
* tasks.db/crow.db, minus init-db.js, with just the tables the handler
* actually touches on this path — plus pi_bot_defs (empty), which is what
* bot-board.js's tableMissing() gates the whole panel on. An empty
* pi_bot_defs still reaches the switcher/perch-link markup (the "no bot
* selected" branch renders it too), so no bot fixture row is needed.
*/
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import Database from "better-sqlite3";

const dir = mkdtempSync(join(tmpdir(), "bot-board-perch-link-"));
process.env.CROW_TASKS_DB_PATH = join(dir, "tasks.db");
process.env.CROW_DB_PATH = join(dir, "crow.db");

{
const t = new Database(process.env.CROW_TASKS_DB_PATH);
t.exec(`CREATE TABLE tasks_items (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
description TEXT, status TEXT NOT NULL DEFAULT 'pending', priority INTEGER DEFAULT 3,
due_date TEXT, phase TEXT, owner TEXT, tags TEXT, parent_id INTEGER, project_id INTEGER,
assigned_bot TEXT, plan_ref TEXT, board_id INTEGER, data_json TEXT NOT NULL DEFAULT '{}',
archived_at TEXT,
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), completed_at TEXT)`);
t.close();

const c = new Database(process.env.CROW_DB_PATH);
c.exec(`CREATE TABLE pi_bot_defs (bot_id TEXT PRIMARY KEY, display_name TEXT, definition TEXT,
enabled INTEGER NOT NULL DEFAULT 1, project_id INTEGER)`);
c.exec(`CREATE TABLE bot_sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, bot_id TEXT NOT NULL,
card_id INTEGER, status TEXT NOT NULL DEFAULT 'active', control TEXT NOT NULL DEFAULT 'run',
pi_session_dir TEXT, kind TEXT NOT NULL DEFAULT 'chat', updated_at TEXT DEFAULT (datetime('now')))`);
c.close();
}

let botBoardPanel, createDbClient;
before(async () => {
({ default: botBoardPanel } = await import("../servers/gateway/dashboard/panels/bot-board.js"));
({ createDbClient } = await import("../servers/db.js"));
});
after(() => { rmSync(dir, { recursive: true, force: true }); });

const layout = (o) => o.content;

async function render(lang = "en") {
const db = createDbClient();
try {
return await botBoardPanel.handler({ method: "GET", query: {} }, {}, { db, layout, lang });
} finally { db.close(); }
}

test("the bot board links back to Perch", async () => {
const html = await render("en");
assert.match(html, /<a href="\/dashboard\/perch"[^>]*>[^<]*<\/a>/, "a plain anchor to /dashboard/perch must render");
});

test("the perch link text is i18n'd, not hardcoded, and differs between en and es", async () => {
const en = await render("en");
const es = await render("es");
const enMatch = en.match(/<a href="\/dashboard\/perch"[^>]*>([^<]*)<\/a>/);
const esMatch = es.match(/<a href="\/dashboard\/perch"[^>]*>([^<]*)<\/a>/);
assert.ok(enMatch, "en render must carry the link");
assert.ok(esMatch, "es render must carry the link");
assert.notEqual(enMatch[1], "", "link text must not be empty");
assert.notEqual(enMatch[1], esMatch[1], "es text must be a real translation, not a copy of en");
});
Loading
Loading