agent-orchestrator/ui-electrobun/src/mainview/TaskBoardTab.svelte
Hibryda 162b5417e4 feat(electrobun): hierarchical state tree (Rule 58)
New files:
- project-state.types.ts: all per-project state interfaces
- project-state.svelte.ts: unified per-project state with version counter
- app-state.svelte.ts: root facade re-exporting all stores as appState.*

Rewired components (no more local $state):
- ProjectCard: reads via appState.agent.* and appState.project.tab.*
- TerminalTabs: state in appState.project.terminals.*
- FileBrowser: state in appState.project.files.*
- CommsTab: state in appState.project.comms.*
- TaskBoardTab: state in appState.project.tasks.*

All follow Rule 57 (no $derived with new objects) and Rule 58
(state tree architecture, components are pure renderers).
2026-03-24 15:20:09 +01:00

514 lines
14 KiB
Svelte

<script lang="ts">
import { appRpc } from './rpc.ts';
import { appState, type Task } from './app-state.svelte.ts';
interface Props {
groupId: string;
projectId: string;
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
agentId?: string;
}
let { groupId, projectId, agentId = 'admin' }: Props = $props();
// Read task state from project state tree
function getTasks() { return appState.project.getState(projectId).tasks; }
// ── Constants ──────────────────────────────────────────────────────
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
const COL_LABELS: Record<string, string> = {
todo: 'To Do', progress: 'In Progress', review: 'Review',
done: 'Done', blocked: 'Blocked',
};
const PRIORITY_COLORS: Record<string, string> = {
high: 'var(--ctp-red)', medium: 'var(--ctp-peach)',
low: 'var(--ctp-teal)',
};
// ── Derived ──────────────────────────────────────────────────────────
function getTasksByCol(): Record<string, Task[]> {
return COLUMNS.reduce((acc, col) => {
acc[col] = getTasks().tasks.filter(t => t.status === col);
return acc;
}, {} as Record<string, Task[]>);
}
// ── Data fetching ────────────────────────────────────────────────────
async function loadTasks() {
const tokenAtStart = appState.project.tasks.nextPollToken(projectId);
try {
const res = await appRpc.request['bttask.listTasks']({ groupId });
if (tokenAtStart < appState.project.tasks.getPollToken(projectId)) return;
appState.project.tasks.setState(projectId, 'tasks', res.tasks);
} catch (err) {
console.error('[TaskBoard] loadTasks:', err);
}
}
async function createTask() {
const t = getTasks();
const title = t.newTitle.trim();
if (!title) { appState.project.tasks.setState(projectId, 'error', 'Title required'); return; }
appState.project.tasks.setState(projectId, 'error', '');
try {
const res = await appRpc.request['bttask.createTask']({
title,
description: t.newDesc.trim(),
priority: t.newPriority,
groupId,
createdBy: agentId,
});
if (res.ok) {
appState.project.tasks.setMulti(projectId, {
newTitle: '', newDesc: '', newPriority: 'medium', showCreateForm: false,
});
await loadTasks();
} else {
appState.project.tasks.setState(projectId, 'error', res.error ?? 'Failed to create task');
}
} catch (err) {
console.error('[TaskBoard] createTask:', err);
appState.project.tasks.setState(projectId, 'error', 'Failed to create task');
}
}
async function moveTask(taskId: string, newStatus: string) {
const t = getTasks();
const task = t.tasks.find(tk => tk.id === taskId);
if (!task || task.status === newStatus) return;
appState.project.tasks.nextPollToken(projectId); // Invalidate in-flight polls
try {
const res = await appRpc.request['bttask.updateTaskStatus']({
taskId,
status: newStatus,
expectedVersion: task.version,
});
if (res.ok) {
task.status = newStatus;
task.version = res.newVersion ?? task.version + 1;
appState.project.tasks.setState(projectId, 'tasks', [...t.tasks]);
} else {
appState.project.tasks.setState(projectId, 'error', res.error ?? 'Version conflict');
await loadTasks();
}
} catch (err) {
console.error('[TaskBoard] moveTask:', err);
await loadTasks();
}
}
async function deleteTask(taskId: string) {
try {
await appRpc.request['bttask.deleteTask']({ taskId });
appState.project.tasks.setState(projectId, 'tasks',
getTasks().tasks.filter(t => t.id !== taskId));
} catch (err) {
console.error('[TaskBoard] deleteTask:', err);
}
}
// ── Drag handlers ────────────────────────────────────────────────────
function onDragStart(e: DragEvent, taskId: string) {
appState.project.tasks.setState(projectId, 'draggedTaskId', taskId);
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', taskId);
}
}
function onDragOver(e: DragEvent, col: string) {
e.preventDefault();
appState.project.tasks.setState(projectId, 'dragOverCol', col);
}
function onDragLeave() {
appState.project.tasks.setState(projectId, 'dragOverCol', null);
}
function onDrop(e: DragEvent, col: string) {
e.preventDefault();
appState.project.tasks.setState(projectId, 'dragOverCol', null);
const t = getTasks();
if (t.draggedTaskId) {
moveTask(t.draggedTaskId, col);
appState.project.tasks.setState(projectId, 'draggedTaskId', null);
}
}
function onDragEnd() {
appState.project.tasks.setMulti(projectId, { draggedTaskId: null, dragOverCol: null });
}
// ── Init + event-driven updates (Feature 4) ─────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
function onTaskChanged(payload: { groupId: string }) {
if (!payload.groupId || payload.groupId === groupId) loadTasks();
}
import { onMount } from 'svelte';
onMount(() => {
loadTasks();
appRpc.addMessageListener('bttask.changed', onTaskChanged);
pollTimer = setInterval(loadTasks, 30000);
return () => {
if (pollTimer) clearInterval(pollTimer);
appRpc.removeMessageListener?.('bttask.changed', onTaskChanged);
};
});
</script>
<div class="task-board">
<!-- Toolbar -->
<div class="tb-toolbar">
<span class="tb-title">Task Board</span>
<span class="tb-count">{getTasks().tasks.length} tasks</span>
<button class="tb-add-btn" onclick={() => { appState.project.tasks.setState(projectId, 'showCreateForm', !getTasks().showCreateForm); }}>
{getTasks().showCreateForm ? 'Cancel' : '+ Task'}
</button>
</div>
<!-- Create form -->
{#if getTasks().showCreateForm}
<div class="tb-create-form">
<input
class="tb-input"
type="text"
placeholder="Task title"
value={getTasks().newTitle}
oninput={(e) => appState.project.tasks.setState(projectId, 'newTitle', (e.target as HTMLInputElement).value)}
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
/>
<input
class="tb-input tb-desc"
type="text"
placeholder="Description (optional)"
value={getTasks().newDesc}
oninput={(e) => appState.project.tasks.setState(projectId, 'newDesc', (e.target as HTMLInputElement).value)}
/>
<div class="tb-form-row">
<select
class="tb-select"
value={getTasks().newPriority}
onchange={(e) => appState.project.tasks.setState(projectId, 'newPriority', (e.target as HTMLSelectElement).value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button class="tb-submit" onclick={createTask}>Create</button>
</div>
{#if getTasks().error}
<span class="tb-error">{getTasks().error}</span>
{/if}
</div>
{/if}
<!-- Kanban columns -->
<div class="tb-columns">
{#each COLUMNS as col}
<div
class="tb-column"
class:drag-over={getTasks().dragOverCol === col}
ondragover={(e) => onDragOver(e, col)}
ondragleave={onDragLeave}
ondrop={(e) => onDrop(e, col)}
role="list"
aria-label="{COL_LABELS[col]} column"
>
<div class="tb-col-header">
<span class="tb-col-label">{COL_LABELS[col]}</span>
<span class="tb-col-count">{getTasksByCol()[col]?.length ?? 0}</span>
</div>
<div class="tb-col-body">
{#each getTasksByCol()[col] ?? [] as task (task.id)}
<div
class="tb-card"
class:dragging={getTasks().draggedTaskId === task.id}
draggable="true"
ondragstart={(e) => onDragStart(e, task.id)}
ondragend={onDragEnd}
role="listitem"
>
<div class="card-header">
<span
class="priority-dot"
style:background={PRIORITY_COLORS[task.priority] ?? 'var(--ctp-overlay0)'}
title="Priority: {task.priority}"
></span>
<span class="card-title">{task.title}</span>
<button
class="card-delete"
onclick={() => deleteTask(task.id)}
title="Delete task"
aria-label="Delete task"
>&times;</button>
</div>
{#if task.description}
<div class="card-desc">{task.description}</div>
{/if}
{#if task.assignedTo}
<div class="card-assignee">
<span class="assignee-icon">@</span>
{task.assignedTo}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<style>
.task-board {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.tb-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--ctp-text);
}
.tb-count {
font-size: 0.6875rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
.tb-add-btn {
padding: 0.125rem 0.5rem;
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
border: 1px solid var(--ctp-green);
border-radius: 0.25rem;
color: var(--ctp-green);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
}
.tb-add-btn:hover {
background: color-mix(in srgb, var(--ctp-green) 30%, transparent);
}
.tb-create-form {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-input {
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.75rem;
padding: 0 0.375rem;
outline: none;
}
.tb-input:focus { border-color: var(--ctp-mauve); }
.tb-form-row {
display: flex;
gap: 0.25rem;
align-items: center;
}
.tb-select {
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
padding: 0 0.25rem;
outline: none;
}
.tb-submit {
padding: 0.125rem 0.625rem;
height: 1.625rem;
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
border: 1px solid var(--ctp-mauve);
border-radius: 0.25rem;
color: var(--ctp-mauve);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.tb-submit:hover {
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
}
.tb-error {
font-size: 0.6875rem;
color: var(--ctp-red);
}
.tb-columns {
display: flex;
flex: 1;
min-height: 0;
overflow-x: auto;
gap: 1px;
background: var(--ctp-surface0);
}
.tb-column {
flex: 1;
min-width: 8rem;
display: flex;
flex-direction: column;
background: var(--ctp-base);
transition: background 0.15s;
}
.tb-column.drag-over {
background: color-mix(in srgb, var(--ctp-blue) 8%, var(--ctp-base));
}
.tb-col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-col-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ctp-subtext0);
}
.tb-col-count {
font-size: 0.625rem;
color: var(--ctp-overlay0);
background: var(--ctp-surface0);
padding: 0 0.25rem;
border-radius: 0.25rem;
}
.tb-col-body {
flex: 1;
overflow-y: auto;
padding: 0.375rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tb-card {
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface0);
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
cursor: grab;
transition: border-color 0.12s, opacity 0.12s;
}
.tb-card:hover { border-color: var(--ctp-surface1); }
.tb-card.dragging { opacity: 0.4; }
.card-header {
display: flex;
align-items: center;
gap: 0.375rem;
}
.priority-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.card-title {
flex: 1;
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-delete {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.875rem;
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.tb-card:hover .card-delete { opacity: 1; }
.card-delete:hover { color: var(--ctp-red); }
.card-desc {
font-size: 0.6875rem;
color: var(--ctp-subtext0);
margin-top: 0.125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-assignee {
font-size: 0.625rem;
color: var(--ctp-overlay1);
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.125rem;
}
.assignee-icon {
color: var(--ctp-blue);
font-weight: 700;
}
</style>