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:
parent
ae4c07c160
commit
162b5417e4
9 changed files with 870 additions and 400 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue