diff --git a/static/i18n.js b/static/i18n.js index 7e36729122..dd4d1c1709 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -530,6 +530,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Title', + kanban_description: 'Description', + kanban_description_placeholder: 'Optional — what needs to happen, acceptance criteria, links', + kanban_status: 'Status', + kanban_assignee: 'Assignee', + kanban_assignee_placeholder: 'Optional — leave blank for any worker', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Optional — project or team slug', + kanban_priority: 'Priority', + kanban_priority_hint: 'Higher numbers run first. Default 0.', + kanban_title_required: 'Title is required.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -624,6 +635,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`, session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`, session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 's'}`, + session_lineage_segment_untitled: 'Untitled segment', + session_lineage_segment_open: 'Open lineage segment', new_profile: 'New profile', transcript: 'Transcript', download_transcript: 'Download as Markdown', @@ -1568,6 +1581,17 @@ const LOCALES = { kanban_no_comments: 'コメントなし', kanban_no_events: 'イベントなし', kanban_no_runs: '実行なし', + kanban_title: 'タイトル', + kanban_description: '説明', + kanban_description_placeholder: '任意 — 何をすべきか、受け入れ基準、リンク', + kanban_status: 'ステータス', + kanban_assignee: '担当者', + kanban_assignee_placeholder: '任意 — 空欄で任意のワーカーに', + kanban_tenant: 'テナント', + kanban_tenant_placeholder: '任意 — プロジェクトまたはチームのスラッグ', + kanban_priority: '優先度', + kanban_priority_hint: '値が大きいほど優先されます。既定値は 0。', + kanban_title_required: 'タイトルは必須です。', kanban_new_task: '新規タスク', kanban_add_comment: 'コメント追加', kanban_only_mine: '自分のみ', @@ -1662,6 +1686,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} 件`, session_meta_children: (n) => `${n} 子`, session_meta_segments: (n) => `${n} セグメント`, + session_lineage_segment_untitled: '無題のセグメント', + session_lineage_segment_open: '系譜セグメントを開く', new_profile: '新規プロファイル', transcript: 'トランスクリプト', download_transcript: 'Markdown としてダウンロード', @@ -2439,6 +2465,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Заголовок', + kanban_description: 'Описание', + kanban_description_placeholder: 'Необязательно — что нужно сделать, критерии приёмки, ссылки', + kanban_status: 'Статус', + kanban_assignee: 'Исполнитель', + kanban_assignee_placeholder: 'Необязательно — оставьте пустым для любого исполнителя', + kanban_tenant: 'Арендатор', + kanban_tenant_placeholder: 'Необязательно — слаг проекта или команды', + kanban_priority: 'Приоритет', + kanban_priority_hint: 'Большие числа выполняются первыми. По умолчанию 0.', + kanban_title_required: 'Заголовок обязателен.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -2509,6 +2546,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} сообщ.`, session_meta_children: (n) => `${n} ${n === 1 ? 'дочерн.' : 'дочерн.'}`, session_meta_segments: (n) => `${n} сегм.`, + session_lineage_segment_untitled: 'Сегмент без названия', + session_lineage_segment_open: 'Открыть сегмент цепочки', new_profile: 'Новый профиль', transcript: 'Транскрипт', download_transcript: 'Скачать как Markdown', @@ -3411,6 +3450,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Título', + kanban_description: 'Descripción', + kanban_description_placeholder: 'Opcional — qué hay que hacer, criterios de aceptación, enlaces', + kanban_status: 'Estado', + kanban_assignee: 'Responsable', + kanban_assignee_placeholder: 'Opcional — déjalo en blanco para cualquier trabajador', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Opcional — slug del proyecto o equipo', + kanban_priority: 'Prioridad', + kanban_priority_hint: 'Los números más altos se ejecutan primero. Predeterminado: 0.', + kanban_title_required: 'El título es obligatorio.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -3481,6 +3531,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} mens.`, session_meta_children: (n) => `${n} ${n === 1 ? 'hijo' : 'hijos'}`, session_meta_segments: (n) => `${n} ${n === 1 ? 'segmento' : 'segmentos'}`, + session_lineage_segment_untitled: 'Segmento sin título', + session_lineage_segment_open: 'Abrir segmento de linaje', new_profile: 'Nuevo perfil', transcript: 'Transcripción', download_transcript: 'Descargar como Markdown', @@ -4371,6 +4423,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Titel', + kanban_description: 'Beschreibung', + kanban_description_placeholder: 'Optional — was zu tun ist, Akzeptanzkriterien, Links', + kanban_status: 'Status', + kanban_assignee: 'Zugewiesen an', + kanban_assignee_placeholder: 'Optional — leer lassen für beliebigen Worker', + kanban_tenant: 'Mandant', + kanban_tenant_placeholder: 'Optional — Projekt- oder Team-Slug', + kanban_priority: 'Priorität', + kanban_priority_hint: 'Höhere Zahlen laufen zuerst. Standard: 0.', + kanban_title_required: 'Titel ist erforderlich.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -4431,6 +4494,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} Nachr.`, session_meta_children: (n) => `${n} ${n === 1 ? 'Subagent' : 'Subagents'}`, session_meta_segments: (n) => `${n} Segment${n === 1 ? '' : 'e'}`, + session_lineage_segment_untitled: 'Unbenanntes Segment', + session_lineage_segment_open: 'Liniensegment öffnen', new_profile: 'Neues Profil', transcript: 'Protokoll', download_transcript: 'Als Markdown herunterladen', @@ -5352,6 +5417,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: '标题', + kanban_description: '描述', + kanban_description_placeholder: '可选 — 需要做什么、验收标准、链接', + kanban_status: '状态', + kanban_assignee: '负责人', + kanban_assignee_placeholder: '可选 — 留空表示任意工作器', + kanban_tenant: '租户', + kanban_tenant_placeholder: '可选 — 项目或团队标识', + kanban_priority: '优先级', + kanban_priority_hint: '数值越高越先执行,默认为 0。', + kanban_title_required: '标题为必填项。', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -5424,6 +5500,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} 条消息`, session_meta_children: (n) => `${n} 子会话`, session_meta_segments: (n) => `${n} 段`, + session_lineage_segment_untitled: '未命名段', + session_lineage_segment_open: '打开脉络段', new_profile: '新配置', transcript: '记录', download_transcript: '下载为 Markdown', @@ -6326,6 +6404,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} 則訊息`, session_meta_children: (n) => `${n} 則子`, session_meta_segments: (n) => `${n} 段`, + session_lineage_segment_untitled: '未命名段', + session_lineage_segment_open: '開啟脈絡段', new_profile: '\u65b0\u914d\u7f6e\u6a94', transcript: '\u8a18\u9304', download_transcript: '\u4e0b\u8f09\u8a18\u9304', @@ -6512,6 +6592,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} 則訊息`, session_meta_children: (n) => `${n} 則子`, session_meta_segments: (n) => `${n} 段`, + session_lineage_segment_untitled: '未命名段', + session_lineage_segment_open: '開啟脈絡段', settings_label_model: '\u9810\u8a2d\u6a21\u578b', skill_created: '\u6280\u80fd\u5df2\u5efa\u7acb', skill_file_load_failed: '\u8f09\u5165\u6a94\u6848\u5931\u6557\uff1a', @@ -7387,6 +7469,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: 'Título', + kanban_description: 'Descrição', + kanban_description_placeholder: 'Opcional — o que precisa ser feito, critérios de aceitação, links', + kanban_status: 'Status', + kanban_assignee: 'Responsável', + kanban_assignee_placeholder: 'Opcional — deixe em branco para qualquer worker', + kanban_tenant: 'Tenant', + kanban_tenant_placeholder: 'Opcional — slug do projeto ou equipe', + kanban_priority: 'Prioridade', + kanban_priority_hint: 'Números maiores executam primeiro. Padrão: 0.', + kanban_title_required: 'O título é obrigatório.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -7457,6 +7550,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`, session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`, session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 's'}`, + session_lineage_segment_untitled: 'Segmento sem título', + session_lineage_segment_open: 'Abrir segmento de linhagem', new_profile: 'Novo perfil', transcript: 'Transcrição', download_transcript: 'Baixar como Markdown', @@ -8323,6 +8418,17 @@ const LOCALES = { kanban_no_comments: 'No comments', kanban_no_events: 'No events', kanban_no_runs: 'No runs', + kanban_title: '제목', + kanban_description: '설명', + kanban_description_placeholder: '선택 — 해야 할 일, 수락 기준, 링크', + kanban_status: '상태', + kanban_assignee: '담당자', + kanban_assignee_placeholder: '선택 — 비워두면 누구나 가능', + kanban_tenant: '테넌트', + kanban_tenant_placeholder: '선택 — 프로젝트 또는 팀 슬러그', + kanban_priority: '우선순위', + kanban_priority_hint: '높은 숫자가 먼저 실행됩니다. 기본값: 0.', + kanban_title_required: '제목은 필수입니다.', kanban_new_task: 'New task', kanban_add_comment: 'Add comment', kanban_only_mine: 'Only mine', @@ -8393,6 +8499,8 @@ const LOCALES = { session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`, session_meta_children: (n) => `${n} child${n === 1 ? '' : 'ren'}`, session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 's'}`, + session_lineage_segment_untitled: '제목 없는 세그먼트', + session_lineage_segment_open: '계보 세그먼트 열기', new_profile: 'New profile', transcript: '대화 기록', download_transcript: 'Download as Markdown', diff --git a/static/index.html b/static/index.html index 319901e138..fdd014d28a 100644 --- a/static/index.html +++ b/static/index.html @@ -146,7 +146,7 @@
Kanban
- +
@@ -1262,5 +1262,48 @@

New board

+ + diff --git a/static/panels.js b/static/panels.js index fd10c8206c..8b9dfcc256 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1566,7 +1566,14 @@ function _kanbanLinksHtml(links){ async function createKanbanTask(){ const input = document.getElementById('kanbanNewTaskTitle'); const title = input ? input.value.trim() : ''; - if (!title) return; + if (!title) { + // Empty inline input (or a click on the panel-head "+" via openKanbanCreate) + // — open the full create-task modal so the user has somewhere obvious to + // type and configure the task. Mirrors the cron / skills pattern of routing + // header "+" clicks through to a clearly-modal create surface. + openKanbanCreate(); + return; + } try { const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { method: 'POST', @@ -1578,6 +1585,125 @@ async function createKanbanTask(){ } catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); } } +// ──────────────────────────────────────────────────────────────────────────── +// Kanban: create-task modal (panel-head "+" button entry point). +// +// Same `.kanban-modal-overlay` shell as openKanbanCreateBoard() so the two +// flows look and behave identically (centered card, dim backdrop, ESC closes, +// click-on-backdrop closes). The modal markup lives in static/index.html as +// #kanbanTaskModal — see the section just above . Submit hits the +// existing /api/kanban/tasks POST endpoint (which already accepts title, body, +// assignee, tenant, priority, status — see api/kanban_bridge.py:306). +// ──────────────────────────────────────────────────────────────────────────── + +function openKanbanCreate(){ + // Make sure the user is on the kanban panel so the resulting board reload is + // visible behind the modal. Without this the modal would still work but the + // user could lose context on which panel they triggered it from. + if (typeof switchPanel === 'function' && _currentPanel !== 'kanban') switchPanel('kanban'); + const modal = document.getElementById('kanbanTaskModal'); + if (!modal) return; + // Reset all form fields to defaults. + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + const bodyEl = document.getElementById('kanbanTaskModalBody'); + const statusEl = document.getElementById('kanbanTaskModalStatus'); + const assigneeEl = document.getElementById('kanbanTaskModalAssignee'); + const tenantEl = document.getElementById('kanbanTaskModalTenant'); + const priorityEl = document.getElementById('kanbanTaskModalPriority'); + const errEl = document.getElementById('kanbanTaskModalError'); + const submitBtn = document.getElementById('kanbanTaskModalSubmit'); + if (titleEl) titleEl.value = ''; + if (bodyEl) bodyEl.value = ''; + if (statusEl) statusEl.value = 'triage'; + if (assigneeEl) assigneeEl.value = ''; + if (tenantEl) tenantEl.value = ''; + if (priorityEl) priorityEl.value = '0'; + if (errEl) errEl.textContent = ''; + if (submitBtn) submitBtn.disabled = false; + // Populate datalists from the currently-loaded board so the user sees the + // assignees / tenants the dispatcher already knows about. + const assignees = (_kanbanBoard && Array.isArray(_kanbanBoard.assignees)) ? _kanbanBoard.assignees : []; + const tenants = (_kanbanBoard && Array.isArray(_kanbanBoard.tenants)) ? _kanbanBoard.tenants : []; + const aList = document.getElementById('kanbanTaskModalAssigneeList'); + const tList = document.getElementById('kanbanTaskModalTenantList'); + if (aList) aList.innerHTML = assignees.map(v => ``).join(''); + if (tList) tList.innerHTML = tenants.map(v => ``).join(''); + modal.hidden = false; + // Auto-focus title field on open. setTimeout to wait for paint. + setTimeout(() => { if (titleEl) titleEl.focus(); }, 50); + // Bind ESC to close, and Enter on simple inputs to submit. + document.addEventListener('keydown', _kanbanTaskModalKey); +} + +function closeKanbanTaskModal(){ + const modal = document.getElementById('kanbanTaskModal'); + if (modal) modal.hidden = true; + document.removeEventListener('keydown', _kanbanTaskModalKey); +} + +function _kanbanTaskModalKey(ev){ + if (ev.key === 'Escape') { + ev.preventDefault(); + closeKanbanTaskModal(); + return; + } + if (ev.key === 'Enter' && !ev.shiftKey) { + // Enter submits except when the focus is in the description textarea + // (where Enter should insert a newline). + const target = ev.target; + if (target && target.tagName === 'TEXTAREA') return; + const modal = document.getElementById('kanbanTaskModal'); + if (modal && !modal.hidden) { + ev.preventDefault(); + submitKanbanTaskModal(); + } + } +} + +async function submitKanbanTaskModal(){ + const titleEl = document.getElementById('kanbanTaskModalTitleInput'); + const bodyEl = document.getElementById('kanbanTaskModalBody'); + const statusEl = document.getElementById('kanbanTaskModalStatus'); + const assigneeEl = document.getElementById('kanbanTaskModalAssignee'); + const tenantEl = document.getElementById('kanbanTaskModalTenant'); + const priorityEl = document.getElementById('kanbanTaskModalPriority'); + const errEl = document.getElementById('kanbanTaskModalError'); + const submitBtn = document.getElementById('kanbanTaskModalSubmit'); + const title = titleEl ? titleEl.value.trim() : ''; + if (!title) { + if (errEl) errEl.textContent = t('kanban_title_required') || 'Title is required.'; + if (titleEl) titleEl.focus(); + return; + } + // Build payload — only include fields the user actually filled in so the + // backend can apply its own defaults rather than us forcing empty strings. + const payload = {title}; + if (bodyEl && bodyEl.value.trim()) payload.body = bodyEl.value; + if (statusEl && statusEl.value) payload.status = statusEl.value; + if (assigneeEl && assigneeEl.value.trim()) payload.assignee = assigneeEl.value.trim(); + if (tenantEl && tenantEl.value.trim()) payload.tenant = tenantEl.value.trim(); + if (priorityEl && priorityEl.value !== '' && priorityEl.value !== '0') { + const n = parseInt(priorityEl.value, 10); + if (!Number.isNaN(n)) payload.priority = n; + } + if (submitBtn) submitBtn.disabled = true; + if (errEl) errEl.textContent = ''; + try { + const created = await api('/api/kanban/tasks' + _kanbanBoardQuery(), { + method: 'POST', + body: JSON.stringify(payload), + }); + closeKanbanTaskModal(); + await loadKanban(true); + if (created && created.task && created.task.id) { + await loadKanbanTask(created.task.id); + } + } catch(e) { + if (errEl) errEl.textContent = (e.message || String(e)); + if (submitBtn) submitBtn.disabled = false; + } +} + async function updateKanbanTask(taskId, patch){ if (!taskId || !patch) return; try { diff --git a/static/sessions.js b/static/sessions.js index b243e3e8b0..6e7a8f8c17 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1237,6 +1237,7 @@ let _sessionActionMenu = null; let _sessionActionAnchor = null; let _sessionActionSessionId = null; const _expandedChildSessionKeys = new Set(); +const _expandedLineageKeys = new Set(); let _sessionVisibleSidebarIds = []; const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; @@ -2083,6 +2084,9 @@ function _syncSidebarExpansionForActiveSession(rows, activeSid){ if(Array.isArray(row._child_sessions)&&row._child_sessions.some(child=>child&&child.session_id===activeSid)){ _expandedChildSessionKeys.add(key); } + if(Array.isArray(row._lineage_segments)&&row._lineage_segments.some(seg=>seg&&seg.session_id===activeSid&&seg.session_id!==row.session_id)){ + _expandedLineageKeys.add(key); + } } } @@ -2566,13 +2570,49 @@ function renderSessionListFromCache(){ ts.className='session-time'+(hasAttentionState?' is-hidden':''); ts.textContent=hasAttentionState?'':_formatRelativeSessionTime(tsMs); titleRow.appendChild(title); + // Project color dot: placed BETWEEN title and timestamp, not inside the + // title span. Inside the title span it would be clipped by the ellipsis + // truncation, becoming invisible exactly when the title is long enough + // to need the project marker. As a flex-flow sibling it stays visible + // regardless of title length and sits next to the timestamp on the right. + if(s.project_id){ + const proj=_allProjects.find(p=>p.project_id===s.project_id); + if(proj){ + const dot=document.createElement('span'); + dot.className='session-project-dot'; + dot.style.background=proj.color||'var(--blue)'; + dot.title=proj.name; + titleRow.appendChild(dot); + } + } + const lineageKey=_sidebarLineageKeyForRow(s); const segmentCount=_sessionSegmentCount(s); + const lineageSegments=Array.isArray(s._lineage_segments)?s._lineage_segments.filter(seg=>seg&&seg.session_id&&seg.session_id!==s.session_id):[]; + const canExpandLineageSegments=Boolean(lineageKey&&segmentCount>1&&lineageSegments.length>0); + const lineageSegmentsExpanded=canExpandLineageSegments&&_expandedLineageKeys.has(lineageKey); if(segmentCount>0){ const segmentCountEl=document.createElement('span'); - segmentCountEl.className='session-lineage-count'; + segmentCountEl.className='session-lineage-count'+(canExpandLineageSegments?' expandable':''); const segmentLabel=t('session_meta_segments', segmentCount); segmentCountEl.textContent=segmentLabel; segmentCountEl.title=segmentLabel; + if(canExpandLineageSegments){ + segmentCountEl.setAttribute('role','button'); + segmentCountEl.setAttribute('tabindex','0'); + segmentCountEl.setAttribute('aria-expanded',lineageSegmentsExpanded?'true':'false'); + ['pointerdown','pointerup','click'].forEach(ev=>segmentCountEl.addEventListener(ev,e=>e.stopPropagation())); + const toggleLineageSegments=(e)=>{ + e.preventDefault(); + e.stopPropagation(); + if(_expandedLineageKeys.has(lineageKey)) _expandedLineageKeys.delete(lineageKey); + else _expandedLineageKeys.add(lineageKey); + renderSessionListFromCache(); + }; + segmentCountEl.onclick=toggleLineageSegments; + segmentCountEl.onkeydown=(e)=>{ + if(e.key==='Enter'||e.key===' '){toggleLineageSegments(e);} + }; + } titleRow.appendChild(segmentCountEl); } const childCount=typeof s._child_session_count==='number'?s._child_session_count:(Array.isArray(s._child_sessions)?s._child_sessions.length:0); @@ -2592,21 +2632,6 @@ function renderSessionListFromCache(){ }; titleRow.appendChild(childCountEl); } - // Project color dot: placed BETWEEN title and timestamp, not inside the - // title span. Inside the title span it would be clipped by the ellipsis - // truncation, becoming invisible exactly when the title is long enough - // to need the project marker. As a flex-flow sibling it stays visible - // regardless of title length and sits next to the timestamp on the right. - if(s.project_id){ - const proj=_allProjects.find(p=>p.project_id===s.project_id); - if(proj){ - const dot=document.createElement('span'); - dot.className='session-project-dot'; - dot.style.background=proj.color||'var(--blue)'; - dot.title=proj.name; - titleRow.appendChild(dot); - } - } titleRow.appendChild(ts); sessionText.appendChild(titleRow); const density=(window._sidebarDensity==='detailed'?'detailed':'compact'); @@ -2629,7 +2654,32 @@ function renderSessionListFromCache(){ meta.textContent=metaBits.join(' · '); sessionText.appendChild(meta); } - const lineageKey=_sidebarLineageKeyForRow(s); + if(lineageSegmentsExpanded){ + const lineageList=document.createElement('div'); + lineageList.className='session-lineage-segments'; + ['pointerdown','pointerup','click'].forEach(ev=>lineageList.addEventListener(ev,e=>e.stopPropagation())); + const sortedSegments=[...lineageSegments].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); + for(const seg of sortedSegments){ + const row=document.createElement('button'); + row.type='button'; + row.className='session-lineage-segment'+(activeSidForSidebar&&seg.session_id===activeSidForSidebar?' active':''); + const segTitle=seg.title||t('session_lineage_segment_untitled'); + const segTime=_formatRelativeSessionTime(_sessionTimestampMs(seg)); + row.textContent=`-> ${segTitle} - ${segTime}`; + row.title=t('session_lineage_segment_open'); + row.onclick=async(e)=>{ + e.stopPropagation(); + if(seg.is_cli_session){ + try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:seg.session_id})});} + catch(_e){ /* read-only fallback */ } + } + await loadSession(seg.session_id); + renderSessionListFromCache(); + }; + lineageList.appendChild(row); + } + sessionText.appendChild(lineageList); + } if(childCount>0&&Array.isArray(s._child_sessions)&&_expandedChildSessionKeys.has(lineageKey)){ const childList=document.createElement('div'); childList.className='session-child-sessions'; @@ -2826,7 +2876,7 @@ function renderSessionListFromCache(){ _pointerActive=false; if(_renamingSid) return; if(actions&&actions.contains(e.target)) return; - if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session')) return; + if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session,.session-lineage-count,.session-lineage-segments,.session-lineage-segment')) return; if(_sessionSelectMode){e.stopPropagation();if(!readOnly)toggleSessionSelect(s.session_id);return;} // If the pointer moved enough to be a drag, cancel any pending tap if(_isDragging){clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;_clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50);return;} diff --git a/static/style.css b/static/style.css index d5f4a2f471..f211823714 100644 --- a/static/style.css +++ b/static/style.css @@ -2670,7 +2670,12 @@ main.main.showing-logs > #mainLogs{display:flex;} /* ── Subagent session tree (#494) ── */ .session-lineage-count{display:inline-flex;align-items:center;justify-content:center;height:16px;font-size:10px;font-weight:600;padding:0 6px;border-radius:999px;background:rgba(148,163,184,.14);color:var(--muted);margin-left:6px;flex-shrink:0;user-select:none;cursor:default;} +.session-lineage-count.expandable{cursor:pointer;} +.session-lineage-count.expandable:hover{background:rgba(148,163,184,.24);color:var(--text);} .session-item.active .session-lineage-count{color:var(--accent-text);background:rgba(255,255,255,.14);} +.session-lineage-segments{display:flex;flex-direction:column;gap:3px;margin-top:6px;margin-left:12px;padding-left:8px;border-left:1px dashed rgba(148,163,184,.22);} +.session-lineage-segment{appearance:none;border:0;background:transparent;color:var(--muted);font:inherit;font-size:11px;text-align:left;padding:3px 4px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.session-lineage-segment:hover,.session-lineage-segment.active{background:rgba(148,163,184,.12);color:var(--text);} .session-child-count{display:inline-flex;align-items:center;justify-content:center;height:16px;font-size:10px;font-weight:600;padding:0 6px;border-radius:999px;background:rgba(99,179,237,.16);color:#63b3ed;margin-left:6px;flex-shrink:0;user-select:none;cursor:pointer;} .session-child-count:hover{background:rgba(99,179,237,.26);color:#90cdf4;} .session-child-sessions{display:flex;flex-direction:column;gap:3px;margin-top:6px;margin-left:12px;padding-left:8px;border-left:1px solid var(--border,rgba(255,255,255,.1));} @@ -3499,7 +3504,15 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-modal-row-inline{display:flex;gap:10px;} .kanban-modal-row-inline > *{flex:1;min-width:0;} .kanban-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;} -.kanban-modal-error{color:var(--danger);font-size:11px;margin-top:6px;min-height:14px;} +.kanban-modal-error{ + color:var(--danger,var(--error,#f87171)); + font-size:12px;font-weight:500;margin-top:8px;min-height:14px; +} +.kanban-modal-error:not(:empty){ + padding:8px 10px;border-radius:8px; + background:color-mix(in srgb,var(--danger,var(--error,#f87171)) 12%,transparent); + border:1px solid color-mix(in srgb,var(--danger,var(--error,#f87171)) 35%,transparent); +} .kanban-empty{padding:12px;color:var(--muted);font-size:12px;text-align:center;border:1px dashed var(--border);border-radius:8px;} .kanban-new-task-row{display:flex;gap:6px;align-items:center;} diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index 359aca2206..8afb09d499 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -80,6 +80,108 @@ def test_kanban_write_mvp_has_native_controls_and_api_calls(): assert "kanban-comment-form" in PANELS +def test_kanban_new_task_header_button_opens_modal(): + """Regression: the panel-head '+' button must open a real `.kanban-modal-overlay` + create-task modal (matching the existing create-board modal pattern in the same + file) — NOT silently return when the inline #kanbanNewTaskTitle input is empty. + + Previously the header button was wired straight to createKanbanTask(), which + silently early-exits on empty title — the button looked completely dead. + Now the header button calls openKanbanCreate(), which opens the + #kanbanTaskModal overlay with title / description / status / priority / + assignee / tenant fields. + """ + # 1. Header "+" button is wired to openKanbanCreate(), NOT createKanbanTask(). + assert 'id="kanbanNewTaskBtn"' in INDEX + btn_html = INDEX[INDEX.find('id="kanbanNewTaskBtn"'):] + btn_html = btn_html[: btn_html.find("") + len("")] + assert 'onclick="openKanbanCreate()"' in btn_html, ( + "Panel-head '+' button must call openKanbanCreate() (modal), not " + "createKanbanTask() directly (which silently returns on empty title)." + ) + + # 2. The create-task modal markup exists in index.html, with all the field + # ids the JS / API contract expects. + assert 'id="kanbanTaskModal"' in INDEX + assert 'class="kanban-modal-overlay"' in INDEX[INDEX.find('id="kanbanTaskModal"') - 80:] + for field_id in ( + "kanbanTaskModalTitleInput", + "kanbanTaskModalBody", + "kanbanTaskModalStatus", + "kanbanTaskModalPriority", + "kanbanTaskModalAssignee", + "kanbanTaskModalTenant", + "kanbanTaskModalError", + "kanbanTaskModalSubmit", + ): + assert f'id="{field_id}"' in INDEX, f"create-task modal missing #{field_id}" + + # 3. Modal closes via Cancel button AND backdrop click AND ESC. + assert 'onclick="closeKanbanTaskModal()"' in INDEX + assert "if(event.target===this)closeKanbanTaskModal()" in INDEX + + # 4. openKanbanCreate() unhides the modal, focuses the title field, populates + # assignee/tenant datalists, binds keydown listener. + assert "function openKanbanCreate()" in PANELS + open_fn = re.search( + r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert open_fn, "openKanbanCreate() not found" + body = open_fn.group(1) + assert "modal.hidden = false" in body + assert "kanbanTaskModalAssigneeList" in body + assert "kanbanTaskModalTenantList" in body + assert "_kanbanTaskModalKey" in body # ESC + Enter handler attached + + # 5. closeKanbanTaskModal() hides the modal and unbinds the listener. + assert "function closeKanbanTaskModal()" in PANELS + close_fn = re.search( + r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert close_fn and "modal.hidden = true" in close_fn.group(1) + assert "removeEventListener('keydown', _kanbanTaskModalKey)" in close_fn.group(1) + + # 6. ESC closes; Enter submits (except in the description textarea). + assert "function _kanbanTaskModalKey" in PANELS + key_fn = re.search( + r"function _kanbanTaskModalKey\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert key_fn + key_body = key_fn.group(1) + assert "ev.key === 'Escape'" in key_body + assert "ev.key === 'Enter'" in key_body + assert "TEXTAREA" in key_body # textarea exception preserved + + # 7. submitKanbanTaskModal() POSTs to /api/kanban/tasks, closes modal, + # reloads board, opens detail. + assert "async function submitKanbanTaskModal()" in PANELS + submit_fn = re.search( + r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert submit_fn, "submitKanbanTaskModal() not found" + submit_body = submit_fn.group(1) + assert "api('/api/kanban/tasks'" in submit_body + assert "method: 'POST'" in submit_body + assert "JSON.stringify(payload)" in submit_body + assert "closeKanbanTaskModal()" in submit_body + assert "loadKanban(true)" in submit_body + assert "loadKanbanTask" in submit_body + + # 8. Inline quick-add still works for power-users — typing a title + Enter + # creates immediately. Empty submit falls through to the modal. + assert "async function createKanbanTask()" in PANELS + quick_add = re.search( + r"async function createKanbanTask\(\)\{(.*?)\n\}", PANELS, re.DOTALL + ) + assert quick_add + qa_body = quick_add.group(1) + assert "openKanbanCreate()" in qa_body, ( + "Empty inline-input submit must open the modal, not silently return." + ) + assert "api('/api/kanban/tasks'" in qa_body + + + def test_kanban_board_has_native_css_classes(): for selector in ( ".kanban-board", diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 89e4af5474..f9746b8f62 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -283,7 +283,7 @@ def test_session_segment_count_prefers_visible_collapsed_backend_and_materialize assert json.loads(_run_node(source)) == [3, 25, 3, 0, 0] -def test_sidebar_lineage_segment_badge_is_passive_and_localized(): +def test_sidebar_lineage_segment_badge_is_localized(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") assert "session-lineage-count" in js @@ -291,10 +291,71 @@ def test_sidebar_lineage_segment_badge_is_passive_and_localized(): assert "t('session_meta_segments', segmentCount)" in js assert "titleRow.appendChild(segmentCountEl);" in js assert ".session-lineage-count{" in css - assert "cursor:default" in css - assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" not in js -def test_session_meta_segments_locale_key_is_defined_for_sidebar_locales(): +def test_lineage_segment_expansion_static_contract(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") + assert "const _expandedLineageKeys = new Set();" in js + assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" in js + assert "segmentCountEl.setAttribute('aria-expanded'" in js + assert "_expandedLineageKeys.has(lineageKey)" in js + assert "_expandedLineageKeys.add(lineageKey)" in js + assert "_expandedLineageKeys.delete(lineageKey)" in js + assert "className='session-lineage-segments'" in js + assert "className='session-lineage-segment'" in js + assert "const segTitle=seg.title||t('session_lineage_segment_untitled');" in js + assert "row.title=t('session_lineage_segment_open');" in js + assert "await loadSession(seg.session_id);" in js + assert ".session-lineage-count.expandable{" in css + assert ".session-lineage-count.expandable:hover" in css + assert ".session-lineage-segments{" in css + assert ".session-lineage-segment{" in css + + +def test_active_hidden_lineage_segment_auto_expands_parent(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +const _expandedChildSessionKeys = new Set(); +const _expandedLineageKeys = new Set(); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_syncSidebarExpansionForActiveSession')); +const rows = [{{ + session_id:'seg10', + _lineage_key:'root', + _lineage_segments:[ + {{session_id:'seg10', updated_at:100}}, + {{session_id:'seg9', updated_at:90}}, + {{session_id:'seg8', updated_at:80}}, + ], +}}]; +_syncSidebarExpansionForActiveSession(rows, 'seg8'); +console.log(JSON.stringify({{lineage:[..._expandedLineageKeys], child:[..._expandedChildSessionKeys]}})); +""" + assert json.loads(_run_node(source)) == {"lineage": ["root"], "child": []} + + +def test_lineage_segment_locale_keys_are_defined_for_sidebar_locales(): i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8") - assert i18n.count("session_meta_segments:") >= i18n.count("session_meta_messages:") + required = [ + "session_meta_segments:", + "session_lineage_segment_untitled:", + "session_lineage_segment_open:", + ] + locale_count = i18n.count("session_meta_messages:") + for key in required: + assert i18n.count(key) >= locale_count, f"{key} missing from one or more locale blocks" diff --git a/tests/test_workspace_panel_session_list.py b/tests/test_workspace_panel_session_list.py index bd12d242b7..501fb53bd9 100644 --- a/tests/test_workspace_panel_session_list.py +++ b/tests/test_workspace_panel_session_list.py @@ -21,6 +21,23 @@ STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") +def _extract_js_function_body(src: str, name: str) -> str: + start = src.find(f"function {name}(") + assert start >= 0, f"function {name} not found" + brace = src.find("{", start) + assert brace >= 0, f"function {name} body not found" + depth = 1 + i = brace + 1 + while depth > 0 and i < len(src): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + assert depth == 0, f"function {name} body did not close" + return src[start:i] + + # ── Bug 1: workspace panel header collapse priority ────────────────────────── @@ -157,9 +174,7 @@ def test_dot_appended_to_title_row_not_title(self): of the title and timestamp), not to the title span (which truncates with ellipsis and would clip the dot off long titles).""" # Find _renderOneSession body - idx = SESSIONS_JS.find("function _renderOneSession(") - assert idx >= 0 - body = SESSIONS_JS[idx: idx + 6000] + body = _extract_js_function_body(SESSIONS_JS, "_renderOneSession") # Must append dot to titleRow assert "titleRow.appendChild(dot)" in body, ( "Project dot must be appended to titleRow as a flex sibling, " @@ -175,8 +190,7 @@ def test_dot_placed_between_title_and_timestamp(self): """The dot is appended AFTER title.appendChild and BEFORE ts append — that ordering puts the dot between the title and the timestamp in the flex row.""" - idx = SESSIONS_JS.find("function _renderOneSession(") - body = SESSIONS_JS[idx: idx + 6000] + body = _extract_js_function_body(SESSIONS_JS, "_renderOneSession") title_pos = body.find("titleRow.appendChild(title);") dot_pos = body.find("titleRow.appendChild(dot);") ts_pos = body.find("titleRow.appendChild(ts);")