feat: add native read-only Kanban panel

This commit is contained in:
Manfred
2026-05-04 22:03:49 +02:00
committed by test
parent eeb5dc545d
commit 88bf62b6e4
5 changed files with 483 additions and 2 deletions
+176
View File
@@ -449,6 +449,28 @@ const LOCALES = {
tab_memory: 'Memory',
tab_workspaces: 'Spaces',
tab_profiles: 'Profiles',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Insights',
tab_settings: 'Settings',
@@ -1351,6 +1373,28 @@ const LOCALES = {
tab_memory: 'メモリ',
tab_workspaces: 'スペース',
tab_profiles: 'プロファイル',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: 'ToDo',
tab_insights: 'インサイト',
tab_settings: '設定',
@@ -2095,6 +2139,28 @@ const LOCALES = {
tab_memory: 'Память',
tab_workspaces: 'Рабочие пространства',
tab_profiles: 'Профили',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: 'Список дел',
tab_insights: 'Аналитика',
tab_settings: 'Настройки',
@@ -2933,6 +2999,28 @@ const LOCALES = {
tab_memory: 'Memoria',
tab_workspaces: 'Espacios',
tab_profiles: 'Perfiles',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Analíticas',
tab_settings: 'Ajustes',
@@ -3759,6 +3847,28 @@ const LOCALES = {
tab_memory: 'Gedächtnis',
tab_workspaces: 'Spaces',
tab_profiles: 'Profile',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Statistiken',
tab_settings: 'Einstellungen',
@@ -4606,6 +4716,28 @@ const LOCALES = {
tab_memory: '记忆',
tab_skills: '技能',
tab_tasks: '任务',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: '待办',
tab_insights: '统计',
tab_workspaces: '工作区',
@@ -6454,6 +6586,28 @@ const LOCALES = {
tab_memory: 'Memória',
tab_workspaces: 'Spaces',
tab_profiles: 'Perfis',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: 'Estatísticas',
tab_settings: 'Configurações',
@@ -7262,6 +7416,28 @@ const LOCALES = {
tab_memory: '메모리',
tab_workspaces: '공간',
tab_profiles: 'Agent 프로필',
tab_kanban: 'Kanban',
kanban_board: 'Board',
kanban_visible_tasks: '{0} visible tasks',
kanban_search_tasks: 'Search tasks',
kanban_all_assignees: 'All assignees',
kanban_all_tenants: 'All tenants',
kanban_include_archived: 'Include archived',
kanban_no_matching_tasks: 'No matching tasks',
kanban_no_data: 'No Kanban data',
kanban_unavailable: 'Kanban unavailable',
kanban_read_only: 'Read-only view',
kanban_empty: 'Empty',
kanban_task: 'Task',
kanban_no_description: 'No description',
kanban_refresh: 'Refresh',
kanban_status_triage: 'Triage',
kanban_status_todo: 'Todo',
kanban_status_ready: 'Ready',
kanban_status_running: 'Running',
kanban_status_blocked: 'Blocked',
kanban_status_done: 'Done',
kanban_status_archived: 'Archived',
tab_todos: 'Todos',
tab_insights: '통계',
tab_settings: '설정',
+31
View File
@@ -83,6 +83,7 @@
<nav class="rail" aria-label="Primary navigation">
<button class="rail-btn nav-tab active" data-panel="chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat" aria-label="Chat"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks" aria-label="Tasks"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="rail-btn nav-tab" data-panel="kanban" onclick="switchPanel('kanban')" title="Kanban" data-i18n-title="tab_kanban" aria-label="Kanban"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="rail-btn nav-tab" data-panel="skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills" aria-label="Skills"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="rail-btn nav-tab" data-panel="memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory" aria-label="Memory"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="workspaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
@@ -97,6 +98,7 @@
<div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat" data-i18n-title="tab_chat"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
<button class="nav-tab" data-panel="kanban" data-label="Kanban" onclick="switchPanel('kanban')" title="Kanban" data-i18n-title="tab_kanban"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M16 4v16"/><path d="M3 10h18"/></svg></button>
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2z"/><path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z"/></svg></button>
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
@@ -130,6 +132,23 @@
</div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Kanban panel -->
<div class="panel-view" id="panelKanban">
<div class="panel-head">
<span data-i18n="tab_kanban">Kanban</span>
<div class="panel-head-actions">
<button class="panel-head-btn" id="kanbanRefreshBtn" onclick="loadKanban(true)" title="Refresh" data-i18n-title="kanban_refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
</div>
</div>
<div class="kanban-filter-stack">
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()"></div>
<select id="kanbanAssigneeFilter" onchange="loadKanban(true)" aria-label="Assignee filter"></select>
<select id="kanbanTenantFilter" onchange="loadKanban(true)" aria-label="Tenant filter"></select>
<label class="kanban-check"><input id="kanbanIncludeArchived" type="checkbox" onchange="loadKanban(true)"> <span data-i18n="kanban_include_archived">Include archived</span></label>
</div>
<div class="kanban-summary" id="kanbanSummary"></div>
<div class="kanban-list" id="kanbanList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Skills panel -->
<div class="panel-view" id="panelSkills">
<div class="panel-head">
@@ -613,6 +632,18 @@
<div class="main-view-empty-sub" data-i18n="tasks_empty_sub">Pick a job from the sidebar to view its details and runs, or create a new one.</div>
</div>
</div>
<div id="mainKanban" class="main-view">
<div class="main-view-header">
<div>
<div class="main-view-title" data-i18n="kanban_board">Board</div>
<div class="kanban-readonly" data-i18n="kanban_read_only">Read-only view</div>
</div>
</div>
<div class="kanban-task-preview" id="kanbanTaskPreview" style="display:none"></div>
<div class="kanban-board-wrap">
<div class="kanban-board" id="kanbanBoard"><div style="padding:16px;color:var(--muted);font-size:13px" data-i18n="loading">Loading...</div></div>
</div>
</div>
<div id="mainWorkspaces" class="main-view">
<div class="main-view-header">
<div class="main-view-title" id="workspaceDetailTitle"></div>
+158 -1
View File
@@ -1,5 +1,7 @@
let _currentPanel = 'chat';
let _renamingAppTitlebar = false; // guard against re-entrant rename
let _kanbanBoard = null;
let _kanbanLatestEventId = 0;
let _skillsData = null; // cached skills list
let _cronList = null; // cached cron jobs (array)
let _currentCronDetail = null; // full cron job object
@@ -164,12 +166,13 @@ async function switchPanel(name, opts = {}) {
// showing-<name> class on <main>; no class means chat (the default).
const mainEl = document.querySelector('main.main');
if (mainEl) {
['settings','skills','memory','tasks','workspaces','profiles','insights'].forEach(p => {
['settings','skills','memory','tasks','kanban','workspaces','profiles','insights'].forEach(p => {
mainEl.classList.toggle('showing-' + p, nextPanel === p);
});
}
// Lazy-load panel data
if (nextPanel === 'tasks') await loadCrons();
if (nextPanel === 'kanban') await loadKanban();
if (nextPanel === 'skills') await loadSkills();
if (nextPanel === 'memory') await loadMemory();
if (nextPanel === 'workspaces') await loadWorkspacesPanel();
@@ -856,6 +859,159 @@ async function cronResume(id) {
let _editingCronId = null;
// ── Kanban panel (read-only) ──
function _kanbanColumnLabel(name){ return t('kanban_status_' + name) || name; }
function _kanbanTaskTitle(task){ return task.title || task.summary || task.id || t('kanban_task'); }
function _kanbanTaskBody(task){ return task.body || task.description || task.prompt || ''; }
function _kanbanTaskMeta(task){
const bits = [];
if (task.assignee) bits.push(task.assignee);
if (task.tenant) bits.push(task.tenant);
if (task.priority !== undefined && task.priority !== null) bits.push('P' + task.priority);
if (task.comment_count) bits.push('💬 ' + task.comment_count);
if (task.link_counts && task.link_counts.children) bits.push('↳ ' + task.link_counts.children);
return bits;
}
function _kanbanCurrentFilters(){
const q = $('kanbanSearch') ? $('kanbanSearch').value.trim().toLowerCase() : '';
const assignee = $('kanbanAssigneeFilter') ? $('kanbanAssigneeFilter').value : '';
const tenant = $('kanbanTenantFilter') ? $('kanbanTenantFilter').value : '';
const includeArchived = !!($('kanbanIncludeArchived') && $('kanbanIncludeArchived').checked);
return {q, assignee, tenant, includeArchived};
}
function _kanbanSetSelectOptions(el, values, allLabelKey){
if (!el) return;
const current = el.value;
const opts = [`<option value="">${esc(t(allLabelKey))}</option>`]
.concat((values || []).map(v => `<option value="${esc(v)}">${esc(v)}</option>`));
el.innerHTML = opts.join('');
if ([...el.options].some(o => o.value === current)) el.value = current;
}
function _kanbanVisibleTasks(){
const filters = _kanbanCurrentFilters();
const columns = (_kanbanBoard && _kanbanBoard.columns) || [];
return columns.map(col => {
const tasks = (col.tasks || []).filter(task => {
if (!filters.q) return true;
const haystack = [task.id, _kanbanTaskTitle(task), _kanbanTaskBody(task), task.assignee, task.tenant]
.filter(Boolean).join(' ').toLowerCase();
return haystack.includes(filters.q);
});
return {...col, tasks};
});
}
function _kanbanRenderSidebar(columns){
const list = $('kanbanList');
if (!list) return;
const tasks = columns.flatMap(col => (col.tasks || []).map(task => ({...task, status: task.status || col.name})));
if (!tasks.length) {
list.innerHTML = `<div class="kanban-empty" data-i18n="kanban_no_matching_tasks">${esc(t('kanban_no_matching_tasks'))}</div>`;
return;
}
list.innerHTML = tasks.map(task => {
const meta = _kanbanTaskMeta(task);
return `<button class="kanban-list-item" onclick="loadKanbanTask('${esc(task.id)}')">
<span class="kanban-list-status">${esc(_kanbanColumnLabel(task.status))}</span>
<span class="kanban-list-title">${esc(_kanbanTaskTitle(task))}</span>
${meta.length ? `<span class="kanban-meta">${esc(meta.join(' · '))}</span>` : ''}
</button>`;
}).join('');
}
function _kanbanRenderBoard(){
const board = $('kanbanBoard');
if (!board) return;
if (!_kanbanBoard || !_kanbanBoard.columns) {
board.innerHTML = `<div class="main-view-empty"><div class="main-view-empty-title">${esc(t('kanban_no_data'))}</div></div>`;
return;
}
const columns = _kanbanVisibleTasks();
const total = columns.reduce((n, col) => n + (col.tasks || []).length, 0);
if ($('kanbanSummary')) $('kanbanSummary').textContent = String(t('kanban_visible_tasks')).replace('{0}', total);
_kanbanRenderSidebar(columns);
board.innerHTML = columns.map(col => `
<section class="kanban-column" data-status="${esc(col.name)}">
<div class="kanban-column-head">
<span>${esc(_kanbanColumnLabel(col.name))}</span>
<span class="kanban-count">${(col.tasks || []).length}</span>
</div>
<div class="kanban-column-body">
${(col.tasks || []).length ? col.tasks.map(task => _kanbanCard(task, col.name)).join('') : `<div class="kanban-empty">${esc(t('kanban_empty'))}</div>`}
</div>
</section>
`).join('');
}
function _kanbanCard(task, status){
const meta = _kanbanTaskMeta(task);
const body = _kanbanTaskBody(task);
return `<article class="kanban-card" data-kanban-task-id="${esc(task.id)}" onclick="loadKanbanTask('${esc(task.id)}')" tabindex="0" role="button">
<div class="kanban-card-title">${esc(_kanbanTaskTitle(task))}</div>
${body ? `<div class="kanban-card-body">${esc(body)}</div>` : ''}
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
<div class="kanban-readonly">${esc(t('kanban_read_only'))}</div>
</article>`;
}
async function loadKanban(animate){
const board = $('kanbanBoard');
const list = $('kanbanList');
try {
if (animate && board) board.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:13px">${esc(t('loading'))}</div>`;
const filters = _kanbanCurrentFilters();
const params = new URLSearchParams();
if (filters.assignee) params.set('assignee', filters.assignee);
if (filters.tenant) params.set('tenant', filters.tenant);
if (filters.includeArchived) params.set('include_archived', '1');
const path = '/api/kanban/board' + (params.toString() ? '?' + params.toString() : '');
const config = await api('/api/kanban/config');
const data = await api(path);
if (data && data.changed === false && _kanbanBoard) { _kanbanRenderBoard(); return; }
_kanbanBoard = data || {columns: []};
if ((!_kanbanBoard.columns || !_kanbanBoard.columns.length) && config && config.columns) {
_kanbanBoard.columns = config.columns.map(name => ({name, tasks: []}));
}
_kanbanLatestEventId = Number(_kanbanBoard.latest_event_id || 0);
_kanbanSetSelectOptions($('kanbanAssigneeFilter'), _kanbanBoard.assignees, 'kanban_all_assignees');
_kanbanSetSelectOptions($('kanbanTenantFilter'), _kanbanBoard.tenants, 'kanban_all_tenants');
_kanbanRenderBoard();
} catch(e) {
const msg = `${esc(t('kanban_unavailable'))}: ${esc(e.message || e)}`;
if (board) board.innerHTML = `<div class="main-view-empty"><div class="main-view-empty-title">${msg}</div></div>`;
if (list) list.innerHTML = `<div class="kanban-empty">${msg}</div>`;
}
}
function filterKanban(){ _kanbanRenderBoard(); }
async function loadKanbanTask(taskId){
if (!taskId) return;
try {
const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId));
const task = data.task || {};
const title = _kanbanTaskTitle(task);
const body = _kanbanTaskBody(task) || t('kanban_no_description');
const board = $('kanbanBoard');
if (board) {
board.querySelectorAll('.kanban-card').forEach(card => card.classList.remove('selected'));
Array.from(board.querySelectorAll('.kanban-card')).find(card => card.dataset.kanbanTaskId === taskId)?.classList.add('selected');
}
const preview = $('kanbanTaskPreview');
if (preview) {
const meta = _kanbanTaskMeta(task);
preview.style.display = '';
preview.innerHTML = `<div class="kanban-task-preview-title">${esc(title)}</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}`;
}
showToast(`${t('kanban_task')}: ${title}`);
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
}
function loadTodos() {
const panel = $('todoPanel');
if (!panel) return;
@@ -2548,6 +2704,7 @@ async function switchToProfile(name) {
if (_currentPanel === 'skills') await loadSkills();
if (_currentPanel === 'memory') await loadMemory();
if (_currentPanel === 'tasks') await loadCrons();
if (_currentPanel === 'kanban') await loadKanban();
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
+31 -1
View File
@@ -2145,14 +2145,16 @@ main.main > #mainSettings,
main.main > #mainSkills,
main.main > #mainMemory,
main.main > #mainTasks,
main.main > #mainKanban,
main.main > #mainWorkspaces,
main.main > #mainProfiles,
main.main > #mainInsights{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
main.main.showing-skills > #mainSkills{display:flex;}
main.main.showing-memory > #mainMemory{display:flex;}
main.main.showing-tasks > #mainTasks{display:flex;}
main.main.showing-kanban > #mainKanban{display:flex;}
main.main.showing-workspaces > #mainWorkspaces{display:flex;}
main.main.showing-profiles > #mainProfiles{display:flex;}
#mainSettings{overflow-y:auto;}
@@ -3115,3 +3117,31 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.checkpoint-diff-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);}
.checkpoint-diff-body{padding:12px 16px;overflow-y:auto;flex:1;}
.checkpoint-diff-body pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-all;}
/* ── Kanban native board (read-only MVP) ── */
.kanban-filter-stack{display:flex;flex-direction:column;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);}
.kanban-filter-stack select{width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font-size:12px;}
.kanban-check{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;}
.kanban-summary{padding:8px 12px;color:var(--muted);font-size:12px;border-bottom:1px solid var(--border);}
.kanban-list{flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:6px;}
.kanban-list-item{display:flex;flex-direction:column;align-items:flex-start;gap:3px;width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;background:var(--panel);color:var(--text);text-align:left;cursor:pointer;}
.kanban-list-item:hover{border-color:var(--accent);background:var(--hover);}
.kanban-list-status{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);}
.kanban-list-title{font-size:13px;font-weight:600;line-height:1.35;}
.kanban-board-wrap{flex:1;min-height:0;overflow:auto;padding:16px;background:var(--bg);}
.kanban-board{display:flex;gap:12px;min-height:100%;overflow-x:auto;padding-bottom:8px;}
.kanban-column{display:flex;flex-direction:column;min-width:260px;max-width:320px;flex:1;background:var(--panel);border:1px solid var(--border);border-radius:10px;min-height:240px;}
.kanban-column-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text);}
.kanban-count{font-size:11px;color:var(--muted);background:var(--input-bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;}
.kanban-column-body{display:flex;flex-direction:column;gap:8px;padding:10px;min-height:0;overflow-y:auto;}
.kanban-card{border:1px solid var(--border);border-radius:9px;background:var(--bg);padding:10px;cursor:pointer;box-shadow:var(--shadow-sm);}
.kanban-card:hover,.kanban-card.selected{border-color:var(--accent);}
.kanban-card-title{font-size:13px;font-weight:650;color:var(--text);line-height:1.35;margin-bottom:6px;}
.kanban-card-body{font-size:12px;color:var(--muted);line-height:1.45;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:8px;}
.kanban-meta{font-size:11px;color:var(--muted);line-height:1.35;}
.kanban-readonly{font-size:11px;color:var(--muted);margin-top:6px;}
.kanban-empty{padding:12px;color:var(--muted);font-size:12px;text-align:center;border:1px dashed var(--border);border-radius:8px;}
.kanban-task-preview{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--panel);}
.kanban-task-preview-title{font-size:14px;font-weight:650;color:var(--text);margin-bottom:6px;}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
+87
View File
@@ -0,0 +1,87 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
COMPACT_INDEX = re.sub(r"\s+", "", INDEX)
COMPACT_PANELS = re.sub(r"\s+", "", PANELS)
COMPACT_STYLE = re.sub(r"\s+", "", STYLE)
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
assert 'data-panel="kanban"' in INDEX
assert 'data-i18n-title="tab_kanban"' in INDEX
assert 'onclick="switchPanel(\'kanban\')"' in INDEX
assert 'data-label="Kanban"' in INDEX
kanban_section = INDEX[INDEX.find('id="mainKanban"'):INDEX.find('id="mainWorkspaces"')]
assert "<iframe" not in kanban_section.lower()
def test_kanban_has_sidebar_panel_and_main_board_mounts():
assert '<div class="panel-view" id="panelKanban">' in INDEX
assert 'id="kanbanSearch"' in INDEX
assert 'id="kanbanAssigneeFilter"' in INDEX
assert 'id="kanbanTenantFilter"' in INDEX
assert 'id="kanbanIncludeArchived"' in INDEX
assert 'id="kanbanList"' in INDEX
assert '<div id="mainKanban" class="main-view">' in INDEX
assert 'id="kanbanBoard"' in INDEX
assert 'id="kanbanTaskPreview"' in INDEX
def test_switch_panel_lazy_loads_kanban_and_toggles_main_view():
assert "'kanban'" in re.search(r"\[[^\]]+\]\.forEach\(p => \{\s*mainEl\.classList", PANELS).group(0)
assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS
assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS
def test_kanban_frontend_uses_read_only_relative_api_endpoints():
assert "'/api/kanban/board" in PANELS
assert "api('/api/kanban/tasks/" in PANELS
assert "api('/api/kanban/config" in PANELS
assert "fetch('/api/kanban" not in PANELS
assert "method: 'POST'" not in PANELS[PANELS.find("async function loadKanban"):PANELS.find("async function loadKanbanTask")]
assert "kanban-readonly" in PANELS
assert "kanbanTaskPreview" in PANELS
assert "classList.add('selected')" in PANELS
def test_kanban_board_has_native_css_classes():
for selector in (
".kanban-board",
".kanban-column",
".kanban-card",
".kanban-card-title",
".kanban-meta",
".kanban-readonly",
):
assert selector in STYLE
assert "overflow-x:auto" in COMPACT_STYLE
def test_kanban_i18n_keys_exist_in_every_locale_block():
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
assert len(locale_blocks) >= 8
required_keys = [
"tab_kanban",
"kanban_board",
"kanban_search_tasks",
"kanban_all_assignees",
"kanban_all_tenants",
"kanban_include_archived",
"kanban_visible_tasks",
"kanban_no_matching_tasks",
"kanban_unavailable",
"kanban_read_only",
"kanban_empty",
]
missing = [
f"{locale}:{key}"
for locale, body in locale_blocks
for key in required_keys
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
]
assert missing == []