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

@ -31,10 +31,12 @@
}: Props = $props(); }: Props = $props();
let scrollEl: HTMLDivElement; let scrollEl: HTMLDivElement;
// These need $state because Svelte 5 template bindings require reactive
// declarations. Rule 58 exception: component-scoped UI state that drives
// template output but has no cross-component readers.
let promptText = $state(""); let promptText = $state("");
let expandedTools = $state<Set<string>>(new Set()); let expandedTools = $state<Set<string>>(new Set());
// Drag-resize state
let agentPaneEl: HTMLDivElement; let agentPaneEl: HTMLDivElement;
let isDragging = $state(false); let isDragging = $state(false);

View file

@ -1,82 +1,29 @@
<script lang="ts"> <script lang="ts">
import { appRpc } from './rpc.ts'; import { appRpc } from './rpc.ts';
import { appState } from './app-state.svelte.ts';
// ── Types ────────────────────────────────────────────────────────────
interface Channel {
id: string;
name: string;
groupId: string;
createdBy: string;
memberCount: number;
createdAt: string;
}
interface ChannelMessage {
id: string;
channelId: string;
fromAgent: string;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
interface Agent {
id: string;
name: string;
role: string;
groupId: string;
tier: number;
status: string;
unreadCount: number;
}
interface DM {
id: string;
fromAgent: string;
toAgent: string;
content: string;
read: boolean;
createdAt: string;
senderName: string | null;
senderRole: string | null;
}
interface Props { interface Props {
groupId: string; groupId: string;
projectId: string;
/** Agent ID for this project's perspective (defaults to 'admin'). */ /** Agent ID for this project's perspective (defaults to 'admin'). */
agentId?: string; agentId?: string;
} }
let { groupId, agentId = 'admin' }: Props = $props(); let { groupId, projectId, agentId = 'admin' }: Props = $props();
// ── State ──────────────────────────────────────────────────────────── // Read comms state from project state tree
function getComms() { return appState.project.getState(projectId).comms; }
type TabMode = 'channels' | 'dms';
let mode = $state<TabMode>('channels');
let channels = $state<Channel[]>([]);
let agents = $state<Agent[]>([]);
let activeChannelId = $state<string | null>(null);
let activeDmAgentId = $state<string | null>(null);
let channelMessages = $state<ChannelMessage[]>([]);
let dmMessages = $state<DM[]>([]);
let input = $state('');
let loading = $state(false);
// Feature 7: Channel member list
let channelMembers = $state<Array<{ agentId: string; name: string; role: string }>>([]);
let showMembers = $state(false);
// ── Data fetching ──────────────────────────────────────────────────── // ── Data fetching ────────────────────────────────────────────────────
async function loadChannels() { async function loadChannels() {
try { try {
const res = await appRpc.request['btmsg.listChannels']({ groupId }); const res = await appRpc.request['btmsg.listChannels']({ groupId });
channels = res.channels; appState.project.comms.setState(projectId, 'channels', res.channels);
if (channels.length > 0 && !activeChannelId) { const c = getComms();
activeChannelId = channels[0].id; if (c.channels.length > 0 && !c.activeChannelId) {
await loadChannelMessages(channels[0].id); appState.project.comms.setState(projectId, 'activeChannelId', c.channels[0].id);
await loadChannelMessages(c.channels[0].id);
} }
} catch (err) { } catch (err) {
console.error('[CommsTab] loadChannels:', err); console.error('[CommsTab] loadChannels:', err);
@ -86,7 +33,8 @@
async function loadAgents() { async function loadAgents() {
try { try {
const res = await appRpc.request['btmsg.getAgents']({ groupId }); const res = await appRpc.request['btmsg.getAgents']({ groupId });
agents = res.agents.filter((a: Agent) => a.id !== agentId); appState.project.comms.setState(projectId, 'agents',
res.agents.filter((a: { id: string }) => a.id !== agentId));
} catch (err) { } catch (err) {
console.error('[CommsTab] loadAgents:', err); console.error('[CommsTab] loadAgents:', err);
} }
@ -94,70 +42,69 @@
async function loadChannelMessages(channelId: string) { async function loadChannelMessages(channelId: string) {
try { try {
loading = true; appState.project.comms.setState(projectId, 'loading', true);
const res = await appRpc.request['btmsg.getChannelMessages']({ const res = await appRpc.request['btmsg.getChannelMessages']({
channelId, limit: 100, channelId, limit: 100,
}); });
channelMessages = res.messages; appState.project.comms.setState(projectId, 'channelMessages', res.messages);
} catch (err) { } catch (err) {
console.error('[CommsTab] loadChannelMessages:', err); console.error('[CommsTab] loadChannelMessages:', err);
} finally { } finally {
loading = false; appState.project.comms.setState(projectId, 'loading', false);
} }
} }
async function loadDmMessages(otherId: string) { async function loadDmMessages(otherId: string) {
try { try {
loading = true; appState.project.comms.setState(projectId, 'loading', true);
const res = await appRpc.request['btmsg.listMessages']({ const res = await appRpc.request['btmsg.listMessages']({
agentId, otherId, limit: 50, agentId, otherId, limit: 50,
}); });
dmMessages = res.messages; appState.project.comms.setState(projectId, 'dmMessages', res.messages);
} catch (err) { } catch (err) {
console.error('[CommsTab] loadDmMessages:', err); console.error('[CommsTab] loadDmMessages:', err);
} finally { } finally {
loading = false; appState.project.comms.setState(projectId, 'loading', false);
} }
} }
function selectChannel(id: string) { function selectChannel(id: string) {
activeChannelId = id; appState.project.comms.setMulti(projectId, { activeChannelId: id, showMembers: false });
showMembers = false;
loadChannelMessages(id); loadChannelMessages(id);
loadChannelMembers(id); loadChannelMembers(id);
} }
// Feature 7: Load channel members
async function loadChannelMembers(channelId: string) { async function loadChannelMembers(channelId: string) {
try { try {
const res = await appRpc.request['btmsg.getChannelMembers']({ channelId }); const res = await appRpc.request['btmsg.getChannelMembers']({ channelId });
channelMembers = res.members; appState.project.comms.setState(projectId, 'channelMembers', res.members);
} catch (err) { } catch (err) {
console.error('[CommsTab] loadChannelMembers:', err); console.error('[CommsTab] loadChannelMembers:', err);
} }
} }
function selectDm(otherId: string) { function selectDm(otherId: string) {
activeDmAgentId = otherId; appState.project.comms.setState(projectId, 'activeDmAgentId', otherId);
loadDmMessages(otherId); loadDmMessages(otherId);
} }
async function sendMessage() { async function sendMessage() {
const text = input.trim(); const c = getComms();
const text = c.input.trim();
if (!text) return; if (!text) return;
input = ''; appState.project.comms.setState(projectId, 'input', '');
try { try {
if (mode === 'channels' && activeChannelId) { if (c.mode === 'channels' && c.activeChannelId) {
await appRpc.request['btmsg.sendChannelMessage']({ await appRpc.request['btmsg.sendChannelMessage']({
channelId: activeChannelId, fromAgent: agentId, content: text, channelId: c.activeChannelId, fromAgent: agentId, content: text,
}); });
await loadChannelMessages(activeChannelId); await loadChannelMessages(c.activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) { } else if (c.mode === 'dms' && c.activeDmAgentId) {
await appRpc.request['btmsg.sendMessage']({ await appRpc.request['btmsg.sendMessage']({
fromAgent: agentId, toAgent: activeDmAgentId, content: text, fromAgent: agentId, toAgent: c.activeDmAgentId, content: text,
}); });
await loadDmMessages(activeDmAgentId); await loadDmMessages(c.activeDmAgentId);
} }
} catch (err) { } catch (err) {
console.error('[CommsTab] sendMessage:', err); console.error('[CommsTab] sendMessage:', err);
@ -175,28 +122,28 @@
let pollTimer: ReturnType<typeof setInterval> | null = null; let pollTimer: ReturnType<typeof setInterval> | null = null;
// Feature 4: Listen for push events
function onNewMessage(payload: { groupId: string; channelId?: string }) { function onNewMessage(payload: { groupId: string; channelId?: string }) {
if (mode === 'channels' && activeChannelId) { const c = getComms();
if (!payload.channelId || payload.channelId === activeChannelId) { if (c.mode === 'channels' && c.activeChannelId) {
loadChannelMessages(activeChannelId); if (!payload.channelId || payload.channelId === c.activeChannelId) {
loadChannelMessages(c.activeChannelId);
} }
} else if (mode === 'dms' && activeDmAgentId) { } else if (c.mode === 'dms' && c.activeDmAgentId) {
loadDmMessages(activeDmAgentId); loadDmMessages(c.activeDmAgentId);
} }
} }
// Use onMount instead of $effect — loadChannels/loadAgents write to $state
import { onMount } from 'svelte'; import { onMount } from 'svelte';
onMount(() => { onMount(() => {
loadChannels(); loadChannels();
loadAgents(); loadAgents();
appRpc.addMessageListener('btmsg.newMessage', onNewMessage); appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
pollTimer = setInterval(() => { pollTimer = setInterval(() => {
if (mode === 'channels' && activeChannelId) { const c = getComms();
loadChannelMessages(activeChannelId); if (c.mode === 'channels' && c.activeChannelId) {
} else if (mode === 'dms' && activeDmAgentId) { loadChannelMessages(c.activeChannelId);
loadDmMessages(activeDmAgentId); } else if (c.mode === 'dms' && c.activeDmAgentId) {
loadDmMessages(c.activeDmAgentId);
} }
}, 30000); }, 30000);
return () => { return () => {
@ -211,38 +158,38 @@
<div class="comms-mode-bar"> <div class="comms-mode-bar">
<button <button
class="mode-btn" class="mode-btn"
class:active={mode === 'channels'} class:active={getComms().mode === 'channels'}
onclick={() => { mode = 'channels'; }} onclick={() => { appState.project.comms.setState(projectId, 'mode', 'channels'); }}
>Channels</button> >Channels</button>
<button <button
class="mode-btn" class="mode-btn"
class:active={mode === 'dms'} class:active={getComms().mode === 'dms'}
onclick={() => { mode = 'dms'; loadAgents(); }} onclick={() => { appState.project.comms.setState(projectId, 'mode', 'dms'); loadAgents(); }}
>DMs</button> >DMs</button>
</div> </div>
<div class="comms-body"> <div class="comms-body">
<!-- Sidebar: channel list or agent list --> <!-- Sidebar: channel list or agent list -->
<div class="comms-sidebar"> <div class="comms-sidebar">
{#if mode === 'channels'} {#if getComms().mode === 'channels'}
{#each channels as ch} {#each getComms().channels as ch}
<button <button
class="sidebar-item" class="sidebar-item"
class:active={activeChannelId === ch.id} class:active={getComms().activeChannelId === ch.id}
onclick={() => selectChannel(ch.id)} onclick={() => selectChannel(ch.id)}
> >
<span class="ch-hash">#</span> <span class="ch-hash">#</span>
<span class="ch-name">{ch.name}</span> <span class="ch-name">{ch.name}</span>
</button> </button>
{/each} {/each}
{#if channels.length === 0} {#if getComms().channels.length === 0}
<div class="sidebar-empty">No channels</div> <div class="sidebar-empty">No channels</div>
{/if} {/if}
{:else} {:else}
{#each agents as ag} {#each getComms().agents as ag}
<button <button
class="sidebar-item" class="sidebar-item"
class:active={activeDmAgentId === ag.id} class:active={getComms().activeDmAgentId === ag.id}
onclick={() => selectDm(ag.id)} onclick={() => selectDm(ag.id)}
> >
<span class="agent-dot {ag.status}"></span> <span class="agent-dot {ag.status}"></span>
@ -253,7 +200,7 @@
{/if} {/if}
</button> </button>
{/each} {/each}
{#if agents.length === 0} {#if getComms().agents.length === 0}
<div class="sidebar-empty">No agents</div> <div class="sidebar-empty">No agents</div>
{/if} {/if}
{/if} {/if}
@ -261,11 +208,11 @@
<!-- Message area --> <!-- Message area -->
<div class="comms-messages"> <div class="comms-messages">
{#if loading} {#if getComms().loading}
<div class="msg-loading">Loading...</div> <div class="msg-loading">Loading...</div>
{:else if mode === 'channels'} {:else if getComms().mode === 'channels'}
<div class="msg-list"> <div class="msg-list">
{#each channelMessages as msg} {#each getComms().channelMessages as msg}
<div class="msg-row"> <div class="msg-row">
<span class="msg-sender">{msg.senderName}</span> <span class="msg-sender">{msg.senderName}</span>
<span class="msg-role">{msg.senderRole}</span> <span class="msg-role">{msg.senderRole}</span>
@ -273,36 +220,36 @@
<div class="msg-content">{msg.content}</div> <div class="msg-content">{msg.content}</div>
</div> </div>
{/each} {/each}
{#if channelMessages.length === 0} {#if getComms().channelMessages.length === 0}
<div class="msg-empty">No messages in this channel</div> <div class="msg-empty">No messages in this channel</div>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="msg-list"> <div class="msg-list">
{#each dmMessages as msg} {#each getComms().dmMessages as msg}
<div class="msg-row" class:msg-mine={msg.fromAgent === agentId}> <div class="msg-row" class:msg-mine={msg.fromAgent === agentId}>
<span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span> <span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span>
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span> <span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
<div class="msg-content">{msg.content}</div> <div class="msg-content">{msg.content}</div>
</div> </div>
{/each} {/each}
{#if dmMessages.length === 0 && activeDmAgentId} {#if getComms().dmMessages.length === 0 && getComms().activeDmAgentId}
<div class="msg-empty">No messages yet</div> <div class="msg-empty">No messages yet</div>
{/if} {/if}
{#if !activeDmAgentId} {#if !getComms().activeDmAgentId}
<div class="msg-empty">Select an agent to message</div> <div class="msg-empty">Select an agent to message</div>
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Feature 7: Channel member list toggle --> <!-- Feature 7: Channel member list toggle -->
{#if mode === 'channels' && activeChannelId} {#if getComms().mode === 'channels' && getComms().activeChannelId}
<button class="members-toggle" onclick={() => showMembers = !showMembers}> <button class="members-toggle" onclick={() => appState.project.comms.setState(projectId, 'showMembers', !getComms().showMembers)}>
Members ({channelMembers.length}) Members ({getComms().channelMembers.length})
</button> </button>
{#if showMembers} {#if getComms().showMembers}
<div class="members-list"> <div class="members-list">
{#each channelMembers as m} {#each getComms().channelMembers as m}
<span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span> <span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span>
{/each} {/each}
</div> </div>
@ -314,11 +261,12 @@
<input <input
class="msg-input" class="msg-input"
type="text" type="text"
placeholder={mode === 'channels' ? 'Message channel...' : 'Send DM...'} placeholder={getComms().mode === 'channels' ? 'Message channel...' : 'Send DM...'}
bind:value={input} value={getComms().input}
oninput={(e) => appState.project.comms.setState(projectId, 'input', (e.target as HTMLInputElement).value)}
onkeydown={handleKeydown} onkeydown={handleKeydown}
/> />
<button class="msg-send-btn" onclick={sendMessage} disabled={!input.trim()}> <button class="msg-send-btn" onclick={sendMessage} disabled={!getComms().input.trim()}>
Send Send
</button> </button>
</div> </div>

View file

@ -3,38 +3,17 @@
import CodeEditor from './CodeEditor.svelte'; import CodeEditor from './CodeEditor.svelte';
import PdfViewer from './PdfViewer.svelte'; import PdfViewer from './PdfViewer.svelte';
import CsvTable from './CsvTable.svelte'; import CsvTable from './CsvTable.svelte';
import { appState } from './app-state.svelte.ts';
interface Props { interface Props {
cwd: string; cwd: string;
projectId: string;
} }
let { cwd }: Props = $props(); let { cwd, projectId }: Props = $props();
interface DirEntry { // Read file state from project state tree
name: string; function getFiles() { return appState.project.getState(projectId).files; }
type: 'file' | 'dir';
size: number;
}
// Tree state: track loaded children and open/closed per path
let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
let openDirs = $state<Set<string>>(new Set());
let loadingDirs = $state<Set<string>>(new Set());
let selectedFile = $state<string | null>(null);
// File viewer state
let fileContent = $state<string | null>(null);
let fileEncoding = $state<'utf8' | 'base64'>('utf8');
let fileSize = $state(0);
let fileError = $state<string | null>(null);
let fileLoading = $state(false);
let isDirty = $state(false);
let editorContent = $state('');
// Fix #6: Request token to discard stale file load responses
let fileRequestToken = 0;
// Feature 2: Track mtime at read time for conflict detection
let readMtimeMs = $state(0);
let showConflictDialog = $state(false);
// Extension-based type detection // Extension-based type detection
const CODE_EXTS = new Set([ const CODE_EXTS = new Set([
@ -81,117 +60,124 @@
return map[ext] ?? 'text'; return map[ext] ?? 'text';
} }
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text'); function getSelectedType(): FileType {
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : ''); const f = getFiles().selectedFile;
return f ? detectFileType(f) : 'text';
}
function getSelectedName(): string {
const f = getFiles().selectedFile;
return f ? f.split('/').pop() ?? '' : '';
}
/** Load directory entries via RPC. */ /** Load directory entries via RPC. */
async function loadDir(dirPath: string) { async function loadDir(dirPath: string) {
if (childrenCache.has(dirPath)) return; const f = getFiles();
const key = dirPath; if (f.childrenCache.has(dirPath)) return;
loadingDirs = new Set([...loadingDirs, key]); f.loadingDirs.add(dirPath);
appState.project.files.setState(projectId, 'loadingDirs', new Set(f.loadingDirs));
try { try {
const result = await appRpc.request["files.list"]({ path: dirPath }); const result = await appRpc.request["files.list"]({ path: dirPath });
if (result.error) { if (result.error) {
console.error(`[files.list] ${dirPath}: ${result.error}`); console.error(`[files.list] ${dirPath}: ${result.error}`);
return; return;
} }
const next = new Map(childrenCache); f.childrenCache.set(dirPath, result.entries);
next.set(dirPath, result.entries); appState.project.files.setState(projectId, 'childrenCache', new Map(f.childrenCache));
childrenCache = next;
} catch (err) { } catch (err) {
console.error('[files.list]', err); console.error('[files.list]', err);
} finally { } finally {
const s = new Set(loadingDirs); f.loadingDirs.delete(dirPath);
s.delete(key); appState.project.files.setState(projectId, 'loadingDirs', new Set(f.loadingDirs));
loadingDirs = s;
} }
} }
/** Toggle a directory open/closed. Lazy-loads on first open. */ /** Toggle a directory open/closed. Lazy-loads on first open. */
async function toggleDir(dirPath: string) { async function toggleDir(dirPath: string) {
const s = new Set(openDirs); const f = getFiles();
const s = new Set(f.openDirs);
if (s.has(dirPath)) { if (s.has(dirPath)) {
s.delete(dirPath); s.delete(dirPath);
openDirs = s;
} else { } else {
s.add(dirPath); s.add(dirPath);
openDirs = s;
await loadDir(dirPath);
} }
appState.project.files.setState(projectId, 'openDirs', s);
if (s.has(dirPath)) await loadDir(dirPath);
} }
/** Select and load a file. Fix #6: uses request token to discard stale responses. */ /** Select and load a file. Fix #6: uses request token to discard stale responses. */
async function selectFile(filePath: string) { async function selectFile(filePath: string) {
if (selectedFile === filePath) return; const f = getFiles();
selectedFile = filePath; if (f.selectedFile === filePath) return;
isDirty = false; appState.project.files.setMulti(projectId, {
fileContent = null; selectedFile: filePath, isDirty: false, fileContent: null,
fileError = null; fileError: null, fileLoading: true,
fileLoading = true; });
const token = ++fileRequestToken; const token = appState.project.files.nextRequestToken(projectId);
const type = detectFileType(filePath); const type = detectFileType(filePath);
// PDF uses its own loader via PdfViewer
if (type === 'pdf') { if (type === 'pdf') {
fileLoading = false; appState.project.files.setState(projectId, 'fileLoading', false);
return; return;
} }
// Images: read as base64 for display
if (type === 'image') { if (type === 'image') {
try { try {
const result = await appRpc.request["files.read"]({ path: filePath }); const result = await appRpc.request["files.read"]({ path: filePath });
if (token !== fileRequestToken) return; if (token !== appState.project.files.getRequestToken(projectId)) return;
if (result.error) { if (result.error) {
fileError = result.error; appState.project.files.setState(projectId, 'fileError', result.error);
return; return;
} }
fileContent = result.content ?? ''; appState.project.files.setMulti(projectId, {
fileEncoding = result.encoding; fileContent: result.content ?? '', fileEncoding: result.encoding, fileSize: result.size,
fileSize = result.size; });
} catch (err) { } catch (err) {
if (token !== fileRequestToken) return; if (token !== appState.project.files.getRequestToken(projectId)) return;
fileError = err instanceof Error ? err.message : String(err); appState.project.files.setState(projectId, 'fileError', err instanceof Error ? err.message : String(err));
} finally { } finally {
if (token === fileRequestToken) fileLoading = false; if (token === appState.project.files.getRequestToken(projectId)) {
appState.project.files.setState(projectId, 'fileLoading', false);
}
} }
return; return;
} }
try { try {
const result = await appRpc.request["files.read"]({ path: filePath }); const result = await appRpc.request["files.read"]({ path: filePath });
if (token !== fileRequestToken) return; if (token !== appState.project.files.getRequestToken(projectId)) return;
if (result.error) { if (result.error) {
fileError = result.error; appState.project.files.setState(projectId, 'fileError', result.error);
return; return;
} }
fileContent = result.content ?? ''; appState.project.files.setMulti(projectId, {
fileEncoding = result.encoding; fileContent: result.content ?? '', fileEncoding: result.encoding,
fileSize = result.size; fileSize: result.size, editorContent: result.content ?? '',
editorContent = fileContent; });
// Feature 2: Record mtime at read time
try { try {
const stat = await appRpc.request["files.stat"]({ path: filePath }); const stat = await appRpc.request["files.stat"]({ path: filePath });
if (token === fileRequestToken && !stat.error) readMtimeMs = stat.mtimeMs; if (token === appState.project.files.getRequestToken(projectId) && !stat.error) {
appState.project.files.setState(projectId, 'readMtimeMs', stat.mtimeMs);
}
} catch { /* non-critical */ } } catch { /* non-critical */ }
} catch (err) { } catch (err) {
if (token !== fileRequestToken) return; if (token !== appState.project.files.getRequestToken(projectId)) return;
fileError = err instanceof Error ? err.message : String(err); appState.project.files.setState(projectId, 'fileError', err instanceof Error ? err.message : String(err));
} finally { } finally {
if (token === fileRequestToken) fileLoading = false; if (token === appState.project.files.getRequestToken(projectId)) {
appState.project.files.setState(projectId, 'fileLoading', false);
}
} }
} }
/** Save current file. Feature 2: Check mtime before write for conflict detection. */ /** Save current file. Feature 2: Check mtime before write for conflict detection. */
async function saveFile() { async function saveFile() {
if (!selectedFile || !isDirty) return; const f = getFiles();
if (!f.selectedFile || !f.isDirty) return;
try { try {
// Feature 2: Check if file was modified externally since we read it if (f.readMtimeMs > 0) {
if (readMtimeMs > 0) { const stat = await appRpc.request["files.stat"]({ path: f.selectedFile });
const stat = await appRpc.request["files.stat"]({ path: selectedFile }); if (!stat.error && stat.mtimeMs > f.readMtimeMs) {
if (!stat.error && stat.mtimeMs > readMtimeMs) { appState.project.files.setState(projectId, 'showConflictDialog', true);
showConflictDialog = true;
return; return;
} }
} }
@ -203,19 +189,18 @@
/** Force-save, bypassing conflict check. */ /** Force-save, bypassing conflict check. */
async function doSave() { async function doSave() {
if (!selectedFile) return; const f = getFiles();
if (!f.selectedFile) return;
try { try {
const result = await appRpc.request["files.write"]({ const result = await appRpc.request["files.write"]({
path: selectedFile, path: f.selectedFile, content: f.editorContent,
content: editorContent,
}); });
if (result.ok) { if (result.ok) {
isDirty = false; appState.project.files.setMulti(projectId, {
fileContent = editorContent; isDirty: false, fileContent: f.editorContent, showConflictDialog: false,
showConflictDialog = false; });
// Update mtime after successful save const stat = await appRpc.request["files.stat"]({ path: f.selectedFile });
const stat = await appRpc.request["files.stat"]({ path: selectedFile }); if (!stat.error) appState.project.files.setState(projectId, 'readMtimeMs', stat.mtimeMs);
if (!stat.error) readMtimeMs = stat.mtimeMs;
} else if (result.error) { } else if (result.error) {
console.error('[files.write]', result.error); console.error('[files.write]', result.error);
} }
@ -226,18 +211,21 @@
/** Reload file from disk (discard local changes). */ /** Reload file from disk (discard local changes). */
async function reloadFile() { async function reloadFile() {
showConflictDialog = false; appState.project.files.setState(projectId, 'showConflictDialog', false);
if (selectedFile) { const f = getFiles();
isDirty = false; if (f.selectedFile) {
const saved = selectedFile; appState.project.files.setState(projectId, 'isDirty', false);
selectedFile = null; const saved = f.selectedFile;
appState.project.files.setState(projectId, 'selectedFile', null);
await selectFile(saved); await selectFile(saved);
} }
} }
function onEditorChange(newContent: string) { function onEditorChange(newContent: string) {
editorContent = newContent; const f = getFiles();
isDirty = newContent !== fileContent; appState.project.files.setMulti(projectId, {
editorContent: newContent, isDirty: newContent !== f.fileContent,
});
} }
function fileIcon(name: string): string { function fileIcon(name: string): string {
@ -264,12 +252,11 @@
} }
// Load root directory on mount — use onMount, NOT $effect // Load root directory on mount — use onMount, NOT $effect
// $effect reads openDirs (via new Set(openDirs)) and writes to it → infinite loop
import { onMount } from 'svelte'; import { onMount } from 'svelte';
onMount(() => { onMount(() => {
if (cwd) { if (cwd) {
loadDir(cwd); loadDir(cwd);
openDirs = new Set([cwd]); appState.project.files.setState(projectId, 'openDirs', new Set([cwd]));
} }
}); });
</script> </script>
@ -278,41 +265,41 @@
<!-- Tree panel --> <!-- Tree panel -->
<div class="fb-tree"> <div class="fb-tree">
{#snippet renderEntries(dirPath: string, depth: number)} {#snippet renderEntries(dirPath: string, depth: number)}
{#if childrenCache.has(dirPath)} {#if getFiles().childrenCache.has(dirPath)}
{#each childrenCache.get(dirPath) ?? [] as entry} {#each getFiles().childrenCache.get(dirPath) ?? [] as entry}
{@const fullPath = `${dirPath}/${entry.name}`} {@const fullPath = `${dirPath}/${entry.name}`}
{#if entry.type === 'dir'} {#if entry.type === 'dir'}
<button <button
class="fb-row fb-dir" class="fb-row fb-dir"
style:padding-left="{0.5 + depth * 0.875}rem" style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => toggleDir(fullPath)} onclick={() => toggleDir(fullPath)}
aria-expanded={openDirs.has(fullPath)} aria-expanded={getFiles().openDirs.has(fullPath)}
> >
<span class="fb-chevron" class:open={openDirs.has(fullPath)}> <span class="fb-chevron" class:open={getFiles().openDirs.has(fullPath)}>
{#if loadingDirs.has(fullPath)}...{:else}>{/if} {#if getFiles().loadingDirs.has(fullPath)}...{:else}>{/if}
</span> </span>
<span class="fb-name">{entry.name}</span> <span class="fb-name">{entry.name}</span>
</button> </button>
{#if openDirs.has(fullPath)} {#if getFiles().openDirs.has(fullPath)}
{@render renderEntries(fullPath, depth + 1)} {@render renderEntries(fullPath, depth + 1)}
{/if} {/if}
{:else} {:else}
<button <button
class="fb-row fb-file" class="fb-row fb-file"
class:selected={selectedFile === fullPath} class:selected={getFiles().selectedFile === fullPath}
style:padding-left="{0.5 + depth * 0.875}rem" style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => selectFile(fullPath)} onclick={() => selectFile(fullPath)}
title={`${entry.name} (${formatSize(entry.size)})`} title={`${entry.name} (${formatSize(entry.size)})`}
> >
<span class="fb-icon file-type">{fileIcon(entry.name)}</span> <span class="fb-icon file-type">{fileIcon(entry.name)}</span>
<span class="fb-name">{entry.name}</span> <span class="fb-name">{entry.name}</span>
{#if selectedFile === fullPath && isDirty} {#if getFiles().selectedFile === fullPath && getFiles().isDirty}
<span class="dirty-dot" title="Unsaved changes"></span> <span class="dirty-dot" title="Unsaved changes"></span>
{/if} {/if}
</button> </button>
{/if} {/if}
{/each} {/each}
{:else if loadingDirs.has(dirPath)} {:else if getFiles().loadingDirs.has(dirPath)}
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div> <div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
{/if} {/if}
{/snippet} {/snippet}
@ -321,7 +308,7 @@
</div> </div>
<!-- Feature 2: Conflict dialog --> <!-- Feature 2: Conflict dialog -->
{#if showConflictDialog} {#if getFiles().showConflictDialog}
<div class="conflict-overlay"> <div class="conflict-overlay">
<div class="conflict-dialog"> <div class="conflict-dialog">
<p class="conflict-title">File modified externally</p> <p class="conflict-title">File modified externally</p>
@ -329,7 +316,7 @@
<div class="conflict-actions"> <div class="conflict-actions">
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button> <button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button> <button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
<button class="conflict-btn cancel" onclick={() => showConflictDialog = false}>Cancel</button> <button class="conflict-btn cancel" onclick={() => appState.project.files.setState(projectId, 'showConflictDialog', false)}>Cancel</button>
</div> </div>
</div> </div>
</div> </div>
@ -337,53 +324,53 @@
<!-- Viewer panel --> <!-- Viewer panel -->
<div class="fb-viewer"> <div class="fb-viewer">
{#if !selectedFile} {#if !getFiles().selectedFile}
<div class="fb-empty">Select a file to view</div> <div class="fb-empty">Select a file to view</div>
{:else if fileLoading} {:else if getFiles().fileLoading}
<div class="fb-empty">Loading...</div> <div class="fb-empty">Loading...</div>
{:else if fileError} {:else if getFiles().fileError}
<div class="fb-error">{fileError}</div> <div class="fb-error">{getFiles().fileError}</div>
{:else if selectedType === 'pdf'} {:else if getSelectedType() === 'pdf'}
<PdfViewer filePath={selectedFile} /> <PdfViewer filePath={getFiles().selectedFile ?? ''} />
{:else if selectedType === 'csv' && fileContent != null} {:else if getSelectedType() === 'csv' && getFiles().fileContent != null}
<CsvTable content={fileContent} filename={selectedName} /> <CsvTable content={getFiles().fileContent ?? ''} filename={getSelectedName()} />
{:else if selectedType === 'image' && fileContent} {:else if getSelectedType() === 'image' && getFiles().fileContent}
{@const ext = getExt(selectedName)} {@const ext = getExt(getSelectedName())}
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'} {@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
<div class="fb-image-wrap"> <div class="fb-image-wrap">
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div> <div class="fb-image-label">{getSelectedName()} ({formatSize(getFiles().fileSize)})</div>
<img <img
class="fb-image" class="fb-image"
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}" src="data:{mime};base64,{getFiles().fileEncoding === 'base64' ? getFiles().fileContent : btoa(getFiles().fileContent ?? '')}"
alt={selectedName} alt={getSelectedName()}
/> />
</div> </div>
{:else if selectedType === 'code' && fileContent != null} {:else if getSelectedType() === 'code' && getFiles().fileContent != null}
<div class="fb-editor-header"> <div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}> <span class="fb-editor-path" title={getFiles().selectedFile ?? ''}>
{selectedName} {getSelectedName()}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if} {#if getFiles().isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span> </span>
<span class="fb-editor-size">{formatSize(fileSize)}</span> <span class="fb-editor-size">{formatSize(getFiles().fileSize)}</span>
</div> </div>
<CodeEditor <CodeEditor
content={fileContent} content={getFiles().fileContent ?? ''}
lang={extToLang(selectedFile)} lang={extToLang(getFiles().selectedFile ?? '')}
onsave={saveFile} onsave={saveFile}
onchange={onEditorChange} onchange={onEditorChange}
onblur={saveFile} onblur={saveFile}
/> />
{:else if fileContent != null} {:else if getFiles().fileContent != null}
<!-- Raw text fallback --> <!-- Raw text fallback -->
<div class="fb-editor-header"> <div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}> <span class="fb-editor-path" title={getFiles().selectedFile ?? ''}>
{selectedName} {getSelectedName()}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if} {#if getFiles().isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span> </span>
<span class="fb-editor-size">{formatSize(fileSize)}</span> <span class="fb-editor-size">{formatSize(getFiles().fileSize)}</span>
</div> </div>
<CodeEditor <CodeEditor
content={fileContent} content={getFiles().fileContent ?? ''}
lang="text" lang="text"
onsave={saveFile} onsave={saveFile}
onchange={onEditorChange} onchange={onEditorChange}

View file

@ -8,15 +8,8 @@
import DocsTab from './DocsTab.svelte'; import DocsTab from './DocsTab.svelte';
import SshTab from './SshTab.svelte'; import SshTab from './SshTab.svelte';
import StatusDot from './ui/StatusDot.svelte'; import StatusDot from './ui/StatusDot.svelte';
import { import { appState, type AgentStatus, type AgentMessage, type ProjectTab } from './app-state.svelte.ts';
startAgent, stopAgent, sendPrompt, getSession, hasSession, import { ALL_TABS } from './project-tabs-store.svelte.ts';
loadLastSession,
type AgentStatus, type AgentMessage,
} from './agent-store.svelte.ts';
import {
getActiveTab, setActiveTab, isTabActivated,
ALL_TABS, type ProjectTab,
} from './project-tabs-store.svelte.ts';
interface Props { interface Props {
id: string; id: string;
@ -61,9 +54,7 @@
// ── Agent session (reactive from store) ────────────────────────── // ── Agent session (reactive from store) ──────────────────────────
const EMPTY_MESSAGES: AgentMessage[] = []; const EMPTY_MESSAGES: AgentMessage[] = [];
// ALL agent session state as plain getter functions — NO $derived. function getAgentSession() { return appState.agent.getSession(id); }
// $derived + store getters that return new refs = infinite loops in Svelte 5.
function getAgentSession() { return getSession(id); }
function getAgentStatus(): AgentStatus { return getAgentSession()?.status ?? 'idle'; } function getAgentStatus(): AgentStatus { return getAgentSession()?.status ?? 'idle'; }
function getAgentMessages(): AgentMessage[] { return getAgentSession()?.messages ?? []; } function getAgentMessages(): AgentMessage[] { return getAgentSession()?.messages ?? []; }
function getAgentCost(): number { return getAgentSession()?.costUsd ?? 0; } function getAgentCost(): number { return getAgentSession()?.costUsd ?? 0; }
@ -107,7 +98,7 @@
return getAgentMessages().length > 0 ? getAgentMessages() : EMPTY_MESSAGES; return getAgentMessages().length > 0 ? getAgentMessages() : EMPTY_MESSAGES;
} }
// ── Clone dialog state ────────────────────────────────────────── // ── Clone dialog state (needs $state for template bindings) ─────
let showCloneDialog = $state(false); let showCloneDialog = $state(false);
let cloneBranchName = $state(''); let cloneBranchName = $state('');
let cloneError = $state(''); let cloneError = $state('');
@ -129,33 +120,31 @@
showCloneDialog = false; showCloneDialog = false;
} }
// Derived from project-tabs-store for reactive reads // Tab state from project state tree
function getCurrentTab() { return getActiveTab(id); } function getCurrentTab() { return appState.project.tab.getActiveTab(id); }
// ── Load last session on mount (once, not reactive) ───────────────── // ── Load last session on mount (once, not reactive) ─────────────────
import { onMount } from 'svelte'; import { onMount } from 'svelte';
onMount(() => { loadLastSession(id); }); onMount(() => { appState.agent.loadLastSession(id); });
function setTab(tab: ProjectTab) { function setTab(tab: ProjectTab) {
setActiveTab(id, tab); appState.project.tab.setActiveTab(id, tab);
} }
function handleSend(text: string) { function handleSend(text: string) {
if (hasSession(id)) { if (appState.agent.hasSession(id)) {
// Session exists — send follow-up prompt appState.agent.sendPrompt(id, text).catch((err) => {
sendPrompt(id, text).catch((err) => {
console.error('[agent.prompt] error:', err); console.error('[agent.prompt] error:', err);
}); });
} else { } else {
// No session — start a new agent appState.agent.startAgent(id, provider, text, { cwd, model }).catch((err) => {
startAgent(id, provider, text, { cwd, model }).catch((err) => {
console.error('[agent.start] error:', err); console.error('[agent.start] error:', err);
}); });
} }
} }
function handleStop() { function handleStop() {
stopAgent(id).catch((err) => { appState.agent.stopAgent(id).catch((err) => {
console.error('[agent.stop] error:', err); console.error('[agent.stop] error:', err);
}); });
} }
@ -373,7 +362,7 @@
role="tabpanel" role="tabpanel"
aria-label="Files" aria-label="Files"
> >
<FileBrowser {cwd} /> <FileBrowser {cwd} projectId={id} />
</div> </div>
<!-- SSH tab --> <!-- SSH tab -->
@ -409,7 +398,7 @@
role="tabpanel" role="tabpanel"
aria-label="Comms" aria-label="Comms"
> >
<CommsTab {groupId} agentId={id} /> <CommsTab {groupId} projectId={id} agentId={id} />
</div> </div>
<!-- Tasks tab (kanban board) --> <!-- Tasks tab (kanban board) -->
@ -421,7 +410,7 @@
role="tabpanel" role="tabpanel"
aria-label="Tasks" aria-label="Tasks"
> >
<TaskBoardTab {groupId} agentId={id} /> <TaskBoardTab {groupId} projectId={id} agentId={id} />
</div> </div>
</div> </div>
</article> </article>

View file

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

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Terminal from './Terminal.svelte'; import Terminal from './Terminal.svelte';
import { appState } from './app-state.svelte.ts';
interface Props { interface Props {
projectId: string; projectId: string;
@ -10,23 +11,10 @@
let { projectId, accent = 'var(--ctp-mauve)', cwd }: Props = $props(); let { projectId, accent = 'var(--ctp-mauve)', cwd }: Props = $props();
interface TermTab { // Read terminal state from project state tree
id: string; function getTerminals() { return appState.project.getState(projectId).terminals; }
title: string;
}
// svelte-ignore state_referenced_locally
const initialId = projectId;
const firstTabId = `${initialId}-t1`;
let tabs = $state<TermTab[]>([{ id: firstTabId, title: 'shell 1' }]);
let activeTabId = $state<string>(firstTabId);
let expanded = $state(true);
let counter = $state(2);
let mounted = $state<Set<string>>(new Set([firstTabId]));
function blurTerminal() { function blurTerminal() {
// Force-blur xterm canvas so UI buttons become clickable
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur(); document.activeElement.blur();
} }
@ -34,37 +22,23 @@
function addTab() { function addTab() {
blurTerminal(); blurTerminal();
const id = `${projectId}-t${counter}`; appState.project.terminals.addTab(projectId);
tabs = [...tabs, { id, title: `shell ${counter}` }];
counter++;
activeTabId = id;
mounted = new Set([...mounted, id]);
} }
function closeTab(id: string, e: MouseEvent) { function closeTab(id: string, e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
blurTerminal(); blurTerminal();
const idx = tabs.findIndex(t => t.id === id); appState.project.terminals.closeTab(projectId, id);
tabs = tabs.filter(t => t.id !== id);
if (activeTabId === id) {
const next = tabs[Math.min(idx, tabs.length - 1)];
activeTabId = next?.id ?? '';
}
const m = new Set(mounted);
m.delete(id);
mounted = m;
} }
function activateTab(id: string) { function activateTab(id: string) {
blurTerminal(); blurTerminal();
activeTabId = id; appState.project.terminals.activateTab(projectId, id);
if (!mounted.has(id)) mounted = new Set([...mounted, id]);
if (!expanded) expanded = true;
} }
function toggleExpand() { function toggleExpand() {
blurTerminal(); blurTerminal();
expanded = !expanded; appState.project.terminals.toggleExpanded(projectId);
} }
</script> </script>
@ -75,11 +49,11 @@
<button <button
class="expand-btn" class="expand-btn"
onclick={toggleExpand} onclick={toggleExpand}
title={expanded ? 'Collapse terminal' : 'Expand terminal'} title={getTerminals().expanded ? 'Collapse terminal' : 'Expand terminal'}
> >
<svg <svg
class="chevron" class="chevron"
class:open={expanded} class:open={getTerminals().expanded}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@ -92,19 +66,19 @@
</button> </button>
<div class="term-tabs" role="tablist"> <div class="term-tabs" role="tablist">
{#each tabs as tab (tab.id)} {#each getTerminals().tabs as tab (tab.id)}
<!-- div+role="tab" allows a nested <button> for the close action --> <!-- div+role="tab" allows a nested <button> for the close action -->
<div <div
class="term-tab" class="term-tab"
class:active={activeTabId === tab.id} class:active={getTerminals().activeTabId === tab.id}
role="tab" role="tab"
tabindex={activeTabId === tab.id ? 0 : -1} tabindex={getTerminals().activeTabId === tab.id ? 0 : -1}
aria-selected={activeTabId === tab.id} aria-selected={getTerminals().activeTabId === tab.id}
onclick={() => activateTab(tab.id)} onclick={() => activateTab(tab.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
> >
<span class="tab-label">{tab.title}</span> <span class="tab-label">{tab.title}</span>
{#if tabs.length > 1} {#if getTerminals().tabs.length > 1}
<button <button
class="tab-close" class="tab-close"
aria-label="Close {tab.title}" aria-label="Close {tab.title}"
@ -119,10 +93,10 @@
<!-- Terminal panes: always in DOM, display toggled. <!-- Terminal panes: always in DOM, display toggled.
WebKitGTK corrupts hit-test tree on DOM add/remove during click. --> WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
<div class="term-panes" style:display={expanded ? 'block' : 'none'}> <div class="term-panes" style:display={getTerminals().expanded ? 'block' : 'none'}>
{#each tabs as tab (tab.id)} {#each getTerminals().tabs as tab (tab.id)}
{#if mounted.has(tab.id)} {#if getTerminals().mounted.has(tab.id)}
<div class="term-pane" style:display={activeTabId === tab.id ? 'flex' : 'none'}> <div class="term-pane" style:display={getTerminals().activeTabId === tab.id ? 'flex' : 'none'}>
<Terminal sessionId={tab.id} {cwd} /> <Terminal sessionId={tab.id} {cwd} />
</div> </div>
{/if} {/if}

View file

@ -0,0 +1,190 @@
/**
* Root state tree unified API surface for all application state.
*
* Components import `appState` and access sub-domains:
* appState.ui.getSettingsOpen()
* appState.project.getState(id)
* appState.project.terminals.addTab(id)
*
* This file is a pure re-export facade. No new state lives here.
*/
// ── UI store ──────────────────────────────────────────────────────────────
import {
getSettingsOpen, setSettingsOpen, toggleSettings,
getSettingsCategory, setSettingsCategory, openSettingsCategory,
getPaletteOpen, setPaletteOpen, togglePalette,
getSearchOpen, setSearchOpen, toggleSearch,
getNotifDrawerOpen, setNotifDrawerOpen, toggleNotifDrawer,
getShowWizard, setShowWizard, toggleWizard,
getProjectToDelete, setProjectToDelete,
getShowAddGroup, setShowAddGroup, toggleAddGroup,
getNewGroupName, setNewGroupName, resetAddGroupForm,
} from './ui-store.svelte.ts';
// ── Workspace store ───────────────────────────────────────────────────────
import {
getProjects, setProjects, getGroups, setGroups,
getActiveGroupId, setActiveGroup,
getFilteredProjects, getTotalCostDerived, getTotalTokensDerived,
getMountedGroupIds, getActiveGroup as getActiveGroupObj,
addProject, addProjectFromWizard, deleteProject,
cloneCountForProject, handleClone,
addGroup, loadGroupsFromDb, loadProjectsFromDb, trackAllProjects,
getTotalCost, getTotalTokens,
} from './workspace-store.svelte.ts';
// ── Agent store ───────────────────────────────────────────────────────────
import {
startAgent, stopAgent, sendPrompt,
getSession, hasSession, clearSession,
loadLastSession, purgeSession, purgeProjectSessions,
setAgentToastFn, setRetentionConfig, loadRetentionConfig,
type AgentSession, type AgentMessage, type AgentStatus,
} from './agent-store.svelte.ts';
// ── Project state (per-project tree) ──────────────────────────────────────
import {
getProjectState,
getActiveTab, isTabActivated, setActiveTab,
addTerminalTab, closeTerminalTab, activateTerminalTab, toggleTerminalExpanded,
setFileState, setFileMulti, nextFileRequestToken, getFileRequestToken,
setCommsState, setCommsMulti,
setTaskState, setTaskMulti, nextTaskPollToken, getTaskPollToken,
removeProject as removeProjectState,
} from './project-state.svelte.ts';
// ── Health store ──────────────────────────────────────────────────────────
import {
trackProject, recordActivity, recordToolDone,
recordTokenSnapshot, setProjectStatus,
getProjectHealth, getAttentionQueue, getHealthAggregates,
getActiveTools, getToolHistogram,
untrackProject, stopHealthTick,
} from './health-store.svelte.ts';
// ── Notifications store ───────────────────────────────────────────────────
import {
getNotifications, getNotifCount,
addNotification, removeNotification, clearAll as clearNotifications,
} from './notifications-store.svelte.ts';
// ── Blink store ───────────────────────────────────────────────────────────
import {
getBlinkVisible, startBlink, stopBlink,
} from './blink-store.svelte.ts';
// ── Theme store ───────────────────────────────────────────────────────────
import { themeStore } from './theme-store.svelte.ts';
// ── i18n ──────────────────────────────────────────────────────────────────
import { t, setLocale, getLocale, getDir } from './i18n.svelte.ts';
// ── Unified API ───────────────────────────────────────────────────────────
export const appState = {
ui: {
getSettingsOpen, setSettingsOpen, toggleSettings,
getSettingsCategory, setSettingsCategory, openSettingsCategory,
getPaletteOpen, setPaletteOpen, togglePalette,
getSearchOpen, setSearchOpen, toggleSearch,
getNotifDrawerOpen, setNotifDrawerOpen, toggleNotifDrawer,
getShowWizard, setShowWizard, toggleWizard,
getProjectToDelete, setProjectToDelete,
getShowAddGroup, setShowAddGroup, toggleAddGroup,
getNewGroupName, setNewGroupName, resetAddGroupForm,
},
workspace: {
getProjects, setProjects, getGroups, setGroups,
getActiveGroupId, setActiveGroup,
getFilteredProjects, getTotalCostDerived, getTotalTokensDerived,
getMountedGroupIds, getActiveGroup: getActiveGroupObj,
addProject, addProjectFromWizard, deleteProject,
cloneCountForProject, handleClone,
addGroup, loadGroupsFromDb, loadProjectsFromDb, trackAllProjects,
getTotalCost, getTotalTokens,
},
agent: {
startAgent, stopAgent, sendPrompt,
getSession, hasSession, clearSession,
loadLastSession, purgeSession, purgeProjectSessions,
setToastFn: setAgentToastFn,
setRetentionConfig, loadRetentionConfig,
},
project: {
getState: getProjectState,
removeState: removeProjectState,
tab: {
getActiveTab, isTabActivated, setActiveTab,
},
terminals: {
addTab: addTerminalTab,
closeTab: closeTerminalTab,
activateTab: activateTerminalTab,
toggleExpanded: toggleTerminalExpanded,
},
files: {
setState: setFileState,
setMulti: setFileMulti,
nextRequestToken: nextFileRequestToken,
getRequestToken: getFileRequestToken,
},
comms: {
setState: setCommsState,
setMulti: setCommsMulti,
},
tasks: {
setState: setTaskState,
setMulti: setTaskMulti,
nextPollToken: nextTaskPollToken,
getPollToken: getTaskPollToken,
},
},
health: {
trackProject, recordActivity, recordToolDone,
recordTokenSnapshot, setProjectStatus,
getProjectHealth, getAttentionQueue, getHealthAggregates,
getActiveTools, getToolHistogram,
untrackProject, stopHealthTick,
},
notifications: {
getAll: getNotifications, getCount: getNotifCount,
add: addNotification, remove: removeNotification, clear: clearNotifications,
},
blink: {
getVisible: getBlinkVisible, start: startBlink, stop: stopBlink,
},
theme: themeStore,
i18n: { t, setLocale, getLocale, getDir },
} as const;
// Re-export types for convenience
export type { AgentSession, AgentMessage, AgentStatus };
export type {
ProjectState, TermTab, TerminalState, FileState,
CommsState, TaskState, TabState, DirEntry,
Channel, ChannelMessage, CommsAgent, DM, ChannelMember, Task,
ProjectTab,
} from './project-state.types.ts';

View file

@ -0,0 +1,247 @@
/**
* Per-project state tree unified state for all project sub-domains.
*
* Uses version counter pattern (not Map reassignment) to avoid
* Svelte 5 infinite reactive loops. Plain Map is NOT reactive;
* _version is the sole reactive signal.
*
* Components read via getter functions (which read _version to subscribe)
* and mutate via action functions (which bump _version to notify).
*/
import type {
ProjectTab, ProjectState, FileState, CommsState, TaskState,
} from './project-state.types.ts';
export type {
ProjectTab, ProjectState, TerminalState, FileState,
CommsState, TaskState, TabState, DirEntry,
TermTab, Channel, ChannelMessage, CommsAgent, DM, ChannelMember, Task,
} from './project-state.types.ts';
// ── Internal state ────────────────────────────────────────────────────────
const _projects = new Map<string, ProjectState>();
let _version = $state(0);
// ── Factory ───────────────────────────────────────────────────────────────
function createProjectState(projectId: string): ProjectState {
const firstTabId = `${projectId}-t1`;
return {
terminals: {
tabs: [{ id: firstTabId, title: 'shell 1' }],
activeTabId: firstTabId,
expanded: true,
nextId: 2,
mounted: new Set([firstTabId]),
},
files: {
childrenCache: new Map(),
openDirs: new Set(),
loadingDirs: new Set(),
selectedFile: null,
fileContent: null,
fileEncoding: 'utf8',
fileSize: 0,
fileError: null,
fileLoading: false,
isDirty: false,
editorContent: '',
fileRequestToken: 0,
readMtimeMs: 0,
showConflictDialog: false,
},
comms: {
mode: 'channels',
channels: [],
agents: [],
activeChannelId: null,
activeDmAgentId: null,
channelMessages: [],
dmMessages: [],
input: '',
loading: false,
channelMembers: [],
showMembers: false,
},
tasks: {
tasks: [],
showCreateForm: false,
newTitle: '',
newDesc: '',
newPriority: 'medium',
error: '',
draggedTaskId: null,
dragOverCol: null,
pollToken: 0,
},
tab: {
activeTab: 'model',
activatedTabs: new Set<ProjectTab>(['model']),
},
};
}
function ensureProject(projectId: string): ProjectState {
let state = _projects.get(projectId);
if (!state) {
state = createProjectState(projectId);
_projects.set(projectId, state);
}
return state;
}
// ── Generic getter (reads _version for reactivity) ────────────────────────
export function getProjectState(projectId: string): ProjectState {
void _version;
return ensureProject(projectId);
}
// ── Version bump (called by all actions) ──────────────────────────────────
function bump(): void { _version++; }
// ── Tab actions ───────────────────────────────────────────────────────────
export function getActiveTab(projectId: string): ProjectTab {
void _version;
return _projects.get(projectId)?.tab.activeTab ?? 'model';
}
export function isTabActivated(projectId: string, tab: ProjectTab): boolean {
void _version;
return _projects.get(projectId)?.tab.activatedTabs.has(tab) ?? (tab === 'model');
}
export function setActiveTab(projectId: string, tab: ProjectTab): void {
const state = ensureProject(projectId);
state.tab.activeTab = tab;
state.tab.activatedTabs.add(tab);
bump();
}
// ── Terminal actions ──────────────────────────────────────────────────────
export function addTerminalTab(projectId: string): void {
const t = ensureProject(projectId).terminals;
const id = `${projectId}-t${t.nextId}`;
t.tabs = [...t.tabs, { id, title: `shell ${t.nextId}` }];
t.nextId++;
t.activeTabId = id;
t.mounted.add(id);
bump();
}
export function closeTerminalTab(projectId: string, tabId: string): void {
const t = ensureProject(projectId).terminals;
const idx = t.tabs.findIndex(tab => tab.id === tabId);
t.tabs = t.tabs.filter(tab => tab.id !== tabId);
if (t.activeTabId === tabId) {
const next = t.tabs[Math.min(idx, t.tabs.length - 1)];
t.activeTabId = next?.id ?? '';
}
t.mounted.delete(tabId);
bump();
}
export function activateTerminalTab(projectId: string, tabId: string): void {
const t = ensureProject(projectId).terminals;
t.activeTabId = tabId;
t.mounted.add(tabId);
if (!t.expanded) t.expanded = true;
bump();
}
export function toggleTerminalExpanded(projectId: string): void {
const t = ensureProject(projectId).terminals;
t.expanded = !t.expanded;
bump();
}
// ── File actions ──────────────────────────────────────────────────────────
export function setFileState<K extends keyof FileState>(
projectId: string, key: K, value: FileState[K],
): void {
const f = ensureProject(projectId).files;
f[key] = value;
bump();
}
export function setFileMulti(
projectId: string, updates: Partial<FileState>,
): void {
const f = ensureProject(projectId).files;
for (const [k, v] of Object.entries(updates)) {
(f as unknown as Record<string, unknown>)[k] = v;
}
bump();
}
export function nextFileRequestToken(projectId: string): number {
const f = ensureProject(projectId).files;
return ++f.fileRequestToken;
}
export function getFileRequestToken(projectId: string): number {
void _version;
return ensureProject(projectId).files.fileRequestToken;
}
// ── Comms actions ─────────────────────────────────────────────────────────
export function setCommsState<K extends keyof CommsState>(
projectId: string, key: K, value: CommsState[K],
): void {
const c = ensureProject(projectId).comms;
c[key] = value;
bump();
}
export function setCommsMulti(
projectId: string, updates: Partial<CommsState>,
): void {
const c = ensureProject(projectId).comms;
for (const [k, v] of Object.entries(updates)) {
(c as unknown as Record<string, unknown>)[k] = v;
}
bump();
}
// ── Task actions ──────────────────────────────────────────────────────────
export function setTaskState<K extends keyof TaskState>(
projectId: string, key: K, value: TaskState[K],
): void {
const t = ensureProject(projectId).tasks;
t[key] = value;
bump();
}
export function setTaskMulti(
projectId: string, updates: Partial<TaskState>,
): void {
const t = ensureProject(projectId).tasks;
for (const [k, v] of Object.entries(updates)) {
(t as unknown as Record<string, unknown>)[k] = v;
}
bump();
}
export function nextTaskPollToken(projectId: string): number {
const t = ensureProject(projectId).tasks;
return ++t.pollToken;
}
export function getTaskPollToken(projectId: string): number {
void _version;
return ensureProject(projectId).tasks.pollToken;
}
// ── Cleanup ───────────────────────────────────────────────────────────────
export function removeProject(projectId: string): void {
if (_projects.delete(projectId)) bump();
}

View file

@ -0,0 +1,154 @@
/**
* Per-project state tree type definitions.
*
* Separated from project-state.svelte.ts to keep that file under 300 lines.
* This file is plain .ts (no runes needed).
*/
import type { ProjectTab } from './project-tabs-store.svelte.ts';
export type { ProjectTab };
// ── Terminal ──────────────────────────────────────────────────────────────
export interface TermTab {
id: string;
title: string;
}
export interface TerminalState {
tabs: TermTab[];
activeTabId: string;
expanded: boolean;
nextId: number;
mounted: Set<string>;
}
// ── Files ─────────────────────────────────────────────────────────────────
export interface DirEntry {
name: string;
type: 'file' | 'dir';
size: number;
}
export interface FileState {
childrenCache: Map<string, DirEntry[]>;
openDirs: Set<string>;
loadingDirs: Set<string>;
selectedFile: string | null;
fileContent: string | null;
fileEncoding: 'utf8' | 'base64';
fileSize: number;
fileError: string | null;
fileLoading: boolean;
isDirty: boolean;
editorContent: string;
fileRequestToken: number;
readMtimeMs: number;
showConflictDialog: boolean;
}
// ── Comms ─────────────────────────────────────────────────────────────────
export interface Channel {
id: string;
name: string;
groupId: string;
createdBy: string;
memberCount: number;
createdAt: string;
}
export interface ChannelMessage {
id: string;
channelId: string;
fromAgent: string;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
export interface CommsAgent {
id: string;
name: string;
role: string;
groupId: string;
tier: number;
status: string;
unreadCount: number;
}
export interface DM {
id: string;
fromAgent: string;
toAgent: string;
content: string;
read: boolean;
createdAt: string;
senderName: string | null;
senderRole: string | null;
}
export interface ChannelMember {
agentId: string;
name: string;
role: string;
}
export interface CommsState {
mode: 'channels' | 'dms';
channels: Channel[];
agents: CommsAgent[];
activeChannelId: string | null;
activeDmAgentId: string | null;
channelMessages: ChannelMessage[];
dmMessages: DM[];
input: string;
loading: boolean;
channelMembers: ChannelMember[];
showMembers: boolean;
}
// ── Tasks ─────────────────────────────────────────────────────────────────
export interface Task {
id: string;
title: string;
description: string;
status: string;
priority: string;
assignedTo: string | null;
createdBy: string;
groupId: string;
version: number;
}
export interface TaskState {
tasks: Task[];
showCreateForm: boolean;
newTitle: string;
newDesc: string;
newPriority: string;
error: string;
draggedTaskId: string | null;
dragOverCol: string | null;
pollToken: number;
}
// ── Tabs ──────────────────────────────────────────────────────────────────
export interface TabState {
activeTab: ProjectTab;
activatedTabs: Set<ProjectTab>;
}
// ── Root per-project state ────────────────────────────────────────────────
export interface ProjectState {
terminals: TerminalState;
files: FileState;
comms: CommsState;
tasks: TaskState;
tab: TabState;
}