mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
feat: add native read-only Kanban panel
This commit is contained in:
+176
@@ -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: '설정',
|
||||
|
||||
@@ -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
@@ -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
@@ -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;}
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user