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();
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 expandedTools = $state<Set<string>>(new Set());
// Drag-resize state
let agentPaneEl: HTMLDivElement;
let isDragging = $state(false);

View file

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

View file

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

View file

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

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}

View file

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