summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-16 00:56:16 +0000
committerClaudomator Agent <agent@claudomator>2026-03-16 00:56:16 +0000
commit7f6254cdafc6143f80ee9ca8e482c36aff2c197e (patch)
tree79219e0ba80ac67c7483568082ea9a951f4d386c /web
parent908fb8650ce1f295b6047fe95a75ec4aa235b7dc (diff)
feat: replace static subtask placeholder with task description
When a BLOCKED/READY task has no subtasks yet, show the task description (truncated to ~120 chars at a word boundary) instead of the generic 'Waiting for subtasks…' text. Falls back to task.name if no description, and finally to the original generic text if neither is present. - Add truncateToWordBoundary(text, maxLen=120) helper - Update renderSubtaskRollup(task, footer) to use task object instead of taskId - Update both READY and BLOCKED call sites - Add web/test/subtask-placeholder.test.mjs with 11 tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js17
-rw-r--r--web/test/subtask-placeholder.test.mjs92
2 files changed, 104 insertions, 5 deletions
diff --git a/web/app.js b/web/app.js
index a2b0ea9..408ffce 100644
--- a/web/app.js
+++ b/web/app.js
@@ -96,6 +96,12 @@ export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefi
return span;
}
+function truncateToWordBoundary(text, maxLen = 120) {
+ if (!text || text.length <= maxLen) return text;
+ const cut = text.lastIndexOf(' ', maxLen);
+ return (cut > 0 ? text.slice(0, cut) : text.slice(0, maxLen)) + '…';
+}
+
function createTaskCard(task) {
const card = document.createElement('div');
card.className = 'task-card';
@@ -175,7 +181,7 @@ function createTaskCard(task) {
});
footer.appendChild(btn);
} else if (task.state === 'READY') {
- renderSubtaskRollup(task.id, footer);
+ renderSubtaskRollup(task, footer);
const acceptBtn = document.createElement('button');
acceptBtn.className = 'btn-accept';
acceptBtn.textContent = 'Accept';
@@ -196,7 +202,7 @@ function createTaskCard(task) {
if (task.question) {
renderQuestionFooter(task, footer);
} else {
- renderSubtaskRollup(task.id, footer);
+ renderSubtaskRollup(task, footer);
}
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-cancel';
@@ -867,17 +873,18 @@ const STATE_EMOJI = {
READY: 'πŸ‘€', BLOCKED: '⏸',
};
-async function renderSubtaskRollup(taskId, footer) {
+async function renderSubtaskRollup(task, footer) {
footer.addEventListener('click', (e) => e.stopPropagation());
const container = document.createElement('div');
container.className = 'subtask-rollup';
footer.prepend(container);
try {
- const res = await fetch(`${API_BASE}/api/tasks/${taskId}/subtasks`);
+ const res = await fetch(`${API_BASE}/api/tasks/${task.id}/subtasks`);
const subtasks = await res.json();
if (!subtasks || subtasks.length === 0) {
- container.textContent = 'Waiting for subtasks…';
+ const blurb = task.description || task.name;
+ container.textContent = blurb ? truncateToWordBoundary(blurb) : 'Waiting for subtasks…';
return;
}
const ul = document.createElement('ul');
diff --git a/web/test/subtask-placeholder.test.mjs b/web/test/subtask-placeholder.test.mjs
new file mode 100644
index 0000000..2449faa
--- /dev/null
+++ b/web/test/subtask-placeholder.test.mjs
@@ -0,0 +1,92 @@
+// subtask-placeholder.test.mjs β€” placeholder text for empty subtask rollup
+//
+// Run with: node --test web/test/subtask-placeholder.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Logic under test ──────────────────────────────────────────────────────────
+//
+// When a task is BLOCKED/READY and the subtask list is empty, the rollup shows
+// meaningful content instead of a generic placeholder:
+// 1. task.description truncated to ~120 chars (word boundary)
+// 2. fallback to task.name if no description
+// 3. fallback to 'Waiting for subtasks…' if neither
+
+function truncateToWordBoundary(text, maxLen = 120) {
+ if (!text || text.length <= maxLen) return text;
+ const cut = text.lastIndexOf(' ', maxLen);
+ return (cut > 0 ? text.slice(0, cut) : text.slice(0, maxLen)) + '…';
+}
+
+function getSubtaskPlaceholder(task) {
+ const blurb = task.description || task.name;
+ return blurb ? truncateToWordBoundary(blurb) : 'Waiting for subtasks…';
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('truncateToWordBoundary', () => {
+ it('returns text unchanged when shorter than maxLen', () => {
+ assert.equal(truncateToWordBoundary('hello world', 120), 'hello world');
+ });
+
+ it('returns text unchanged when exactly maxLen', () => {
+ const text = 'a'.repeat(120);
+ assert.equal(truncateToWordBoundary(text, 120), text);
+ });
+
+ it('truncates at word boundary and appends ellipsis', () => {
+ const text = 'Fix the login bug that causes users to be logged out unexpectedly when they navigate between pages in the application user interface';
+ const result = truncateToWordBoundary(text, 120);
+ assert.ok(result.length <= 121, `result too long: ${result.length}`);
+ assert.ok(result.endsWith('…'), 'should end with ellipsis');
+ assert.ok(result.startsWith('Fix the'), 'should keep beginning of text');
+ });
+
+ it('truncates at character boundary when no word boundary exists', () => {
+ const text = 'a'.repeat(200);
+ const result = truncateToWordBoundary(text, 120);
+ assert.equal(result, 'a'.repeat(120) + '…');
+ });
+
+ it('returns empty string unchanged', () => {
+ assert.equal(truncateToWordBoundary(''), '');
+ });
+
+ it('returns null unchanged', () => {
+ assert.equal(truncateToWordBoundary(null), null);
+ });
+});
+
+describe('getSubtaskPlaceholder', () => {
+ it('uses task.description when available', () => {
+ const task = { description: 'Fix auth bug', name: 'auth-fix' };
+ assert.equal(getSubtaskPlaceholder(task), 'Fix auth bug');
+ });
+
+ it('truncates task.description at 120 chars', () => {
+ const longDesc = 'This is a very long description that exceeds the limit. ' +
+ 'It goes on and on describing what the task is about in great detail. ' +
+ 'Eventually it reaches the maximum allowed length.';
+ const task = { description: longDesc, name: 'long-task' };
+ const result = getSubtaskPlaceholder(task);
+ assert.ok(result.endsWith('…'), 'should end with ellipsis');
+ assert.ok(result.length <= 122, `result too long: ${result.length}`);
+ });
+
+ it('falls back to task.name when description is absent', () => {
+ const task = { name: 'deploy-frontend' };
+ assert.equal(getSubtaskPlaceholder(task), 'deploy-frontend');
+ });
+
+ it('falls back to task.name when description is empty string', () => {
+ const task = { description: '', name: 'deploy-frontend' };
+ assert.equal(getSubtaskPlaceholder(task), 'deploy-frontend');
+ });
+
+ it('falls back to generic text when both description and name are absent', () => {
+ const task = {};
+ assert.equal(getSubtaskPlaceholder(task), 'Waiting for subtasks…');
+ });
+});