mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
feat: add Unified Work Hub /work three-pane surface (fixes #197)
Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
1 parent
2ebb56f609
commit
00bbdd29a6
6 files changed
+332
-1
No files matched your search
@@ -3324,6 +3324,14 @@ def create_app(
|
||||
"description": "Async agent jobs",
|
||||
"order": 40,
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"title": "Work Hub",
|
||||
"icon": "🗂️",
|
||||
"group": "Work",
|
||||
"description": "Unified kanban, card detail, chat and job log",
|
||||
"order": 9,
|
||||
},
|
||||
{
|
||||
"id": "jobs-board",
|
||||
"title": "Jobs Board",
|
||||
|
||||
@@ -525,6 +525,7 @@ export function createInteractiveBoard(root, opts = {}) {
|
||||
selected,
|
||||
onSelect: opts.interactive !== false ? onSelect : null,
|
||||
onOpen: (card) => {
|
||||
if (typeof opts.onOpen === 'function') { opts.onOpen(card); return; }
|
||||
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
|
||||
openDrawer = openTaskDrawer(card, {
|
||||
apiBase: drawerApiBase,
|
||||
|
||||
@@ -171,6 +171,7 @@ const BUILTIN_VIEWS = {
|
||||
feedback: '/plugins/views/feedback.js',
|
||||
jobs: '/plugins/views/jobs.js',
|
||||
kanban: '/plugins/views/kanban.js',
|
||||
work: '/plugins/views/work-hub.js',
|
||||
'jobs-board': '/plugins/views/jobs-board.js',
|
||||
auth: '/plugins/views/auth.js',
|
||||
api: '/plugins/views/api.js',
|
||||
|
||||
@@ -10,7 +10,11 @@ export async function render(container) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'aiui-board-page-header';
|
||||
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>';
|
||||
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>'
|
||||
+ '<div style="margin:0 0 12px;padding:8px 12px;border:1px solid rgba(99,102,241,.3);'
|
||||
+ 'border-radius:8px;background:rgba(99,102,241,.06);font-size:13px">'
|
||||
+ 'Try the new <a href="/work" style="color:var(--db-accent,#6366f1);font-weight:600">Work Hub</a>'
|
||||
+ ' — kanban, card detail, chat and job logs on one screen.</div>';
|
||||
container.appendChild(header);
|
||||
|
||||
const root = document.createElement('div');
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Unified Work Hub (/work) — three-pane operator surface (STITCH-003).
|
||||
*
|
||||
* Pane 1: Kanban board (reuses createInteractiveBoard from board.js).
|
||||
* Pane 2: Selected card detail with Description / Chat / Tools / Traces tabs.
|
||||
* Pane 3: Activity — job runs, live log, trace link for the card's latest run.
|
||||
*
|
||||
* Card selection updates panes 2+3 without a page navigation. URL state
|
||||
* (?board=&card=&tab=) is kept in sync for deep links.
|
||||
*/
|
||||
import { createInteractiveBoard, relativeTime, escapeHtml } from '../board.js';
|
||||
|
||||
const STYLE_ID = 'work-hub-styles';
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.work-hub { display:flex; height:100%; min-height:0; gap:1px; background:var(--db-border,#3f3f46); }
|
||||
.work-hub-pane { background:var(--db-bg,#18181b); overflow:auto; min-height:0; }
|
||||
.work-hub-p1 { width:300px; min-width:240px; flex:0 0 auto; }
|
||||
.work-hub-p2 { flex:1 1 auto; min-width:0; }
|
||||
.work-hub-p3 { width:320px; min-width:280px; flex:0 0 auto; }
|
||||
.work-hub-pane-head { padding:10px 14px; border-bottom:1px solid var(--db-border,#3f3f46);
|
||||
font-size:13px; font-weight:600; position:sticky; top:0; background:var(--db-bg,#18181b); z-index:2; }
|
||||
.work-hub-empty { padding:24px; text-align:center; color:var(--db-text-dim,#a1a1aa); font-size:13px; }
|
||||
.work-hub-tabs { display:flex; gap:4px; padding:8px 14px 0; border-bottom:1px solid var(--db-border,#3f3f46); }
|
||||
.work-hub-tab { padding:6px 12px; font-size:13px; cursor:pointer; border:none; background:none;
|
||||
color:var(--db-text-dim,#a1a1aa); border-bottom:2px solid transparent; }
|
||||
.work-hub-tab.active { color:var(--db-text,#e4e4e7); border-bottom-color:var(--db-accent,#6366f1); }
|
||||
.work-hub-tab:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.work-hub-tabbody { padding:14px; }
|
||||
.work-hub-chat-log { display:flex; flex-direction:column; gap:8px; margin-bottom:12px; }
|
||||
.work-hub-chat-msg { padding:8px 10px; border-radius:8px; font-size:13px; line-height:1.5;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); }
|
||||
.work-hub-chat-msg .role { font-size:11px; font-weight:600; color:var(--db-accent,#6366f1); }
|
||||
.work-hub-composer { display:flex; gap:8px; align-items:flex-end; }
|
||||
.work-hub-composer textarea { flex:1; resize:none; min-height:40px; max-height:64px; padding:8px 10px;
|
||||
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); border-radius:8px;
|
||||
color:var(--db-text,#e4e4e7); font-size:13px; box-sizing:border-box; }
|
||||
.work-hub-log { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;
|
||||
line-height:1.6; white-space:pre-wrap; background:var(--db-card-bg,#27272a);
|
||||
border:1px solid var(--db-border,#3f3f46); border-radius:8px; padding:10px; max-height:40vh; overflow:auto; }
|
||||
.work-hub-job { display:flex; align-items:center; gap:8px; padding:6px 0; font-size:13px; }
|
||||
.work-hub-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
|
||||
@media (max-width:1024px) {
|
||||
.work-hub { flex-direction:column; }
|
||||
.work-hub-p1, .work-hub-p2, .work-hub-p3 { width:auto; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function statusColor(status) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
if (s === 'done' || s === 'completed' || s === 'success') return '#22c55e';
|
||||
if (s === 'running' || s === 'in_progress') return '#f59e0b';
|
||||
if (s === 'failed' || s === 'error' || s === 'blocked') return '#ef4444';
|
||||
return '#6366f1';
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'work');
|
||||
container.classList.add('work-hub-page');
|
||||
container.innerHTML = '';
|
||||
injectStyles();
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const boardState = { id: params.get('board') || 'default' };
|
||||
let selectedCard = null;
|
||||
let activeTab = params.get('tab') || 'description';
|
||||
const pendingCardId = params.get('card');
|
||||
|
||||
const hub = document.createElement('div');
|
||||
hub.className = 'work-hub';
|
||||
container.appendChild(hub);
|
||||
|
||||
const p1 = document.createElement('div');
|
||||
p1.className = 'work-hub-pane work-hub-p1';
|
||||
const p1Body = document.createElement('div');
|
||||
p1.appendChild(p1Body);
|
||||
hub.appendChild(p1);
|
||||
|
||||
const p2 = document.createElement('div');
|
||||
p2.className = 'work-hub-pane work-hub-p2';
|
||||
hub.appendChild(p2);
|
||||
|
||||
const p3 = document.createElement('div');
|
||||
p3.className = 'work-hub-pane work-hub-p3';
|
||||
hub.appendChild(p3);
|
||||
|
||||
function syncUrl() {
|
||||
const q = new URLSearchParams();
|
||||
q.set('board', boardState.id);
|
||||
if (selectedCard) { q.set('card', selectedCard.id); q.set('tab', activeTab); }
|
||||
const url = `${window.location.pathname}?${q.toString()}`;
|
||||
window.history.replaceState({}, '', url);
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
if (!selectedCard) {
|
||||
p2.innerHTML = '<div class="work-hub-empty">Select a card to see its details.</div>';
|
||||
return;
|
||||
}
|
||||
const c = selectedCard;
|
||||
const meta = c.meta || {};
|
||||
const hasAgent = !!(c.assignee || meta.agent_id);
|
||||
const tabs = [
|
||||
['description', 'Description', false],
|
||||
['chat', 'Chat', !hasAgent],
|
||||
['tools', 'Tools', false],
|
||||
['traces', 'Traces', false],
|
||||
];
|
||||
p2.innerHTML = `
|
||||
<div class="work-hub-pane-head">${escapeHtml(c.title || 'Card')}
|
||||
<span class="db-text-dim" style="font-weight:400;font-size:12px;margin-left:8px">${escapeHtml(c.status || '')}</span>
|
||||
</div>
|
||||
<div class="work-hub-tabs">
|
||||
${tabs.map(([id, label, disabled]) =>
|
||||
`<button class="work-hub-tab ${id === activeTab ? 'active' : ''}" data-tab="${id}" ${disabled ? 'disabled' : ''}>${label}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
<div class="work-hub-tabbody" id="work-hub-tabbody"></div>`;
|
||||
|
||||
p2.querySelectorAll('.work-hub-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (btn.disabled) return;
|
||||
activeTab = btn.getAttribute('data-tab');
|
||||
syncUrl();
|
||||
renderDetail();
|
||||
});
|
||||
});
|
||||
|
||||
const body = p2.querySelector('#work-hub-tabbody');
|
||||
if (activeTab === 'description') {
|
||||
body.innerHTML = c.body
|
||||
? `<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${escapeHtml(c.body)}</div>`
|
||||
: '<div class="work-hub-empty">No description.</div>';
|
||||
} else if (activeTab === 'chat') {
|
||||
renderChat(body, c);
|
||||
} else if (activeTab === 'tools') {
|
||||
const tools = meta.tools || [];
|
||||
body.innerHTML = tools.length
|
||||
? tools.map((t) => `<span class="db-badge" style="display:inline-block;margin:2px;padding:2px 8px;border-radius:6px;background:var(--db-card-bg,#27272a);font-size:12px">${escapeHtml(String(t))}</span>`).join('')
|
||||
: '<div class="work-hub-empty">No tools listed for this card.</div>';
|
||||
} else if (activeTab === 'traces') {
|
||||
const sessionId = meta.session_id;
|
||||
body.innerHTML = sessionId
|
||||
? `<a href="/traces?session_id=${encodeURIComponent(sessionId)}" style="color:var(--db-accent,#6366f1)">Open traces for this session →</a>`
|
||||
: '<div class="work-hub-empty">No traces linked.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderChat(body, card) {
|
||||
const agentId = card.assignee || (card.meta || {}).agent_id || '';
|
||||
body.innerHTML = `
|
||||
<div class="work-hub-chat-log" id="work-hub-chat-log">
|
||||
<div class="work-hub-empty">Ask the assigned agent (${escapeHtml(agentId)}) about this card.</div>
|
||||
</div>
|
||||
<div class="work-hub-composer">
|
||||
<textarea id="work-hub-composer" rows="2" placeholder="Message ${escapeHtml(agentId)}…"></textarea>
|
||||
<button class="db-btn db-btn-sm" id="work-hub-send">Send</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12px"><a href="/chat" style="color:var(--db-accent,#6366f1)">Open full chat →</a></div>`;
|
||||
|
||||
const log = body.querySelector('#work-hub-chat-log');
|
||||
const ta = body.querySelector('#work-hub-composer');
|
||||
const send = body.querySelector('#work-hub-send');
|
||||
send.addEventListener('click', () => {
|
||||
const text = ta.value.trim();
|
||||
if (!text) return;
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'work-hub-chat-msg';
|
||||
msg.innerHTML = `<div class="role">You</div>${escapeHtml(text)}`;
|
||||
if (log.querySelector('.work-hub-empty')) log.innerHTML = '';
|
||||
log.appendChild(msg);
|
||||
ta.value = '';
|
||||
log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderActivity() {
|
||||
if (!selectedCard) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No card selected.</div>';
|
||||
return;
|
||||
}
|
||||
const meta = selectedCard.meta || {};
|
||||
const jobId = meta.job_id;
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Loading…</div>';
|
||||
if (!jobId) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No jobs linked to this card.</div>';
|
||||
return;
|
||||
}
|
||||
let job = null;
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`);
|
||||
if (res.ok) job = await res.json();
|
||||
} catch (_) { /* offline */ }
|
||||
if (!job) {
|
||||
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Could not load job.</div>';
|
||||
return;
|
||||
}
|
||||
const logLines = (job.log || job.output || '').split('\n').slice(-50).join('\n');
|
||||
p3.innerHTML = `
|
||||
<div class="work-hub-pane-head">Activity</div>
|
||||
<div style="padding:14px">
|
||||
<div class="work-hub-job">
|
||||
<span class="work-hub-dot" style="background:${statusColor(job.status)}"></span>
|
||||
<span>${escapeHtml(job.id || jobId)}</span>
|
||||
<span class="db-text-dim" style="margin-left:auto;font-size:12px">${escapeHtml(job.status || '')}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim,#a1a1aa);margin:4px 0 10px">${job.updated_at ? relativeTime(job.updated_at) : ''}</div>
|
||||
<div class="work-hub-log">${escapeHtml(logLines) || 'No log output.'}</div>
|
||||
${meta.session_id ? `<div style="margin-top:10px"><a href="/traces?session_id=${encodeURIComponent(meta.session_id)}" style="color:var(--db-accent,#6366f1);font-size:12px">Open trace →</a></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function selectCard(card) {
|
||||
selectedCard = card;
|
||||
if (card && (!activeTab)) activeTab = 'description';
|
||||
syncUrl();
|
||||
renderDetail();
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
const board = createInteractiveBoard(p1Body, {
|
||||
apiBase: '/api/kanban',
|
||||
eventsUrl: '/api/kanban/events',
|
||||
pollMs: 8000,
|
||||
debounceMs: 500,
|
||||
interactive: true,
|
||||
laneByProfile: true,
|
||||
boardSwitcher: true,
|
||||
boardState,
|
||||
onOpen: (card) => {
|
||||
selectCard(card);
|
||||
if (pendingHandled === false) pendingHandled = true;
|
||||
},
|
||||
fetch: async () => {
|
||||
const [boardRes, boardsRes] = await Promise.all([
|
||||
fetch(`/api/kanban/board?board=${encodeURIComponent(boardState.id)}`),
|
||||
fetch('/api/kanban/boards'),
|
||||
]);
|
||||
if (!boardRes.ok) throw new Error(await boardRes.text());
|
||||
const data = await boardRes.json();
|
||||
if (boardsRes.ok) {
|
||||
const b = await boardsRes.json();
|
||||
data.boards = b.boards || [{ id: 'default', title: 'Default' }];
|
||||
}
|
||||
if (!pendingHandled && pendingCardId) tryDeepLink(data);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
let pendingHandled = !pendingCardId;
|
||||
function tryDeepLink(data) {
|
||||
for (const col of data.columns || []) {
|
||||
for (const card of col.cards || []) {
|
||||
if (card.id === pendingCardId) { pendingHandled = true; selectCard(card); return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderDetail();
|
||||
renderActivity();
|
||||
|
||||
return () => board.destroy();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Work Hub (STITCH-003) registration and wiring tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
VIEWS = FRONTEND / "views"
|
||||
|
||||
|
||||
def test_work_hub_view_file_exists():
|
||||
assert (VIEWS / "work-hub.js").exists()
|
||||
|
||||
|
||||
def test_work_registered_in_builtin_views():
|
||||
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
|
||||
assert "work:" in source
|
||||
assert "/plugins/views/work-hub.js" in source
|
||||
|
||||
|
||||
def test_kanban_banner_links_to_work_hub():
|
||||
source = (VIEWS / "kanban.js").read_text(encoding="utf-8")
|
||||
assert 'href="/work"' in source
|
||||
assert "Work Hub" in source
|
||||
|
||||
|
||||
def test_board_supports_on_open_override():
|
||||
source = (FRONTEND / "board.js").read_text(encoding="utf-8")
|
||||
assert "typeof opts.onOpen === 'function'" in source
|
||||
|
||||
|
||||
def test_work_page_registered_in_server():
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import praisonaiui as aiui
|
||||
import praisonaiui.server as server
|
||||
|
||||
server.reset_state()
|
||||
aiui.set_style("dashboard")
|
||||
app = server.create_app()
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/pages")
|
||||
assert resp.status_code == 200
|
||||
pages = resp.json().get("pages") or []
|
||||
work = next((p for p in pages if p.get("id") == "work"), None)
|
||||
assert work is not None
|
||||
assert work["group"] == "Work"
|
||||
Reference in new issue
Block a user