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).
This commit is contained in:
Hibryda 2026-03-24 15:20:09 +01:00
parent ae4c07c160
commit 162b5417e4
9 changed files with 870 additions and 400 deletions

View file

@ -1,29 +1,20 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────
interface Task {
id: string;
title: string;
description: string;
status: string;
priority: string;
assignedTo: string | null;
createdBy: string;
groupId: string;
version: number;
}
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, agentId = 'admin' }: Props = $props();
let { groupId, projectId, agentId = 'admin' }: Props = $props();
// ── State ────────────────────────────────────────────────────────────
// 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> = {
@ -35,26 +26,11 @@
low: 'var(--ctp-teal)',
};
let tasks = $state<Task[]>([]);
let showCreateForm = $state(false);
let newTitle = $state('');
let newDesc = $state('');
let newPriority = $state('medium');
let error = $state('');
// Drag state
let draggedTaskId = $state<string | null>(null);
let dragOverCol = $state<string | null>(null);
// Fix #7 (Codex audit): Poll token to discard stale responses
let pollToken = $state(0);
// ── Derived ──────────────────────────────────────────────────────────
// NO $derived — .reduce creates new objects every evaluation → infinite loop
function getTasksByCol(): Record<string, Task[]> {
return COLUMNS.reduce((acc, col) => {
acc[col] = tasks.filter(t => t.status === col);
acc[col] = getTasks().tasks.filter(t => t.status === col);
return acc;
}, {} as Record<string, Task[]>);
}
@ -62,50 +38,50 @@
// ── Data fetching ────────────────────────────────────────────────────
async function loadTasks() {
// Fix #7 (Codex audit): Capture token before async call, discard if stale
const tokenAtStart = ++pollToken;
const tokenAtStart = appState.project.tasks.nextPollToken(projectId);
try {
const res = await appRpc.request['bttask.listTasks']({ groupId });
if (tokenAtStart < pollToken) return; // Stale response discard
tasks = res.tasks;
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 title = newTitle.trim();
if (!title) { error = 'Title required'; return; }
error = '';
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: newDesc.trim(),
priority: newPriority,
description: t.newDesc.trim(),
priority: t.newPriority,
groupId,
createdBy: agentId,
});
if (res.ok) {
newTitle = '';
newDesc = '';
newPriority = 'medium';
showCreateForm = false;
appState.project.tasks.setMulti(projectId, {
newTitle: '', newDesc: '', newPriority: 'medium', showCreateForm: false,
});
await loadTasks();
} else {
error = res.error ?? 'Failed to create task';
appState.project.tasks.setState(projectId, 'error', res.error ?? 'Failed to create task');
}
} catch (err) {
console.error('[TaskBoard] createTask:', err);
error = 'Failed to create task';
appState.project.tasks.setState(projectId, 'error', 'Failed to create task');
}
}
async function moveTask(taskId: string, newStatus: string) {
const task = tasks.find(t => t.id === taskId);
const t = getTasks();
const task = t.tasks.find(tk => tk.id === taskId);
if (!task || task.status === newStatus) return;
pollToken++; // Fix #7: Invalidate in-flight polls
appState.project.tasks.nextPollToken(projectId); // Invalidate in-flight polls
try {
const res = await appRpc.request['bttask.updateTaskStatus']({
taskId,
@ -113,13 +89,12 @@
expectedVersion: task.version,
});
if (res.ok) {
// Optimistic update
task.status = newStatus;
task.version = res.newVersion ?? task.version + 1;
tasks = [...tasks]; // trigger reactivity
appState.project.tasks.setState(projectId, 'tasks', [...t.tasks]);
} else {
error = res.error ?? 'Version conflict';
await loadTasks(); // reload on conflict
appState.project.tasks.setState(projectId, 'error', res.error ?? 'Version conflict');
await loadTasks();
}
} catch (err) {
console.error('[TaskBoard] moveTask:', err);
@ -130,7 +105,8 @@
async function deleteTask(taskId: string) {
try {
await appRpc.request['bttask.deleteTask']({ taskId });
tasks = tasks.filter(t => t.id !== taskId);
appState.project.tasks.setState(projectId, 'tasks',
getTasks().tasks.filter(t => t.id !== taskId));
} catch (err) {
console.error('[TaskBoard] deleteTask:', err);
}
@ -139,7 +115,7 @@
// ── Drag handlers ────────────────────────────────────────────────────
function onDragStart(e: DragEvent, taskId: string) {
draggedTaskId = taskId;
appState.project.tasks.setState(projectId, 'draggedTaskId', taskId);
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', taskId);
@ -148,38 +124,35 @@
function onDragOver(e: DragEvent, col: string) {
e.preventDefault();
dragOverCol = col;
appState.project.tasks.setState(projectId, 'dragOverCol', col);
}
function onDragLeave() {
dragOverCol = null;
appState.project.tasks.setState(projectId, 'dragOverCol', null);
}
function onDrop(e: DragEvent, col: string) {
e.preventDefault();
dragOverCol = null;
if (draggedTaskId) {
moveTask(draggedTaskId, col);
draggedTaskId = null;
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() {
draggedTaskId = null;
dragOverCol = null;
appState.project.tasks.setMulti(projectId, { draggedTaskId: null, dragOverCol: null });
}
// ── Init + event-driven updates (Feature 4) ─────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Feature 4: Listen for push events, fallback to 30s poll
function onTaskChanged(payload: { groupId: string }) {
if (!payload.groupId || payload.groupId === groupId) loadTasks();
}
// Use onMount instead of $effect — loadTasks() writes to $state (pollToken++)
// which would trigger $effect re-run → infinite loop
import { onMount } from 'svelte';
onMount(() => {
loadTasks();
@ -196,38 +169,44 @@
<!-- Toolbar -->
<div class="tb-toolbar">
<span class="tb-title">Task Board</span>
<span class="tb-count">{tasks.length} tasks</span>
<button class="tb-add-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
{showCreateForm ? 'Cancel' : '+ Task'}
<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 showCreateForm}
{#if getTasks().showCreateForm}
<div class="tb-create-form">
<input
class="tb-input"
type="text"
placeholder="Task title"
bind:value={newTitle}
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)"
bind:value={newDesc}
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" bind:value={newPriority}>
<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 error}
<span class="tb-error">{error}</span>
{#if getTasks().error}
<span class="tb-error">{getTasks().error}</span>
{/if}
</div>
{/if}
@ -237,7 +216,7 @@
{#each COLUMNS as col}
<div
class="tb-column"
class:drag-over={dragOverCol === col}
class:drag-over={getTasks().dragOverCol === col}
ondragover={(e) => onDragOver(e, col)}
ondragleave={onDragLeave}
ondrop={(e) => onDrop(e, col)}
@ -253,7 +232,7 @@
{#each getTasksByCol()[col] ?? [] as task (task.id)}
<div
class="tb-card"
class:dragging={draggedTaskId === task.id}
class:dragging={getTasks().draggedTaskId === task.id}
draggable="true"
ondragstart={(e) => onDragStart(e, task.id)}
ondragend={onDragEnd}