feat(electrobun): hierarchical state tree (Rule 58)
New files: - project-state.types.ts: all per-project state interfaces - project-state.svelte.ts: unified per-project state with version counter - app-state.svelte.ts: root facade re-exporting all stores as appState.* Rewired components (no more local $state): - ProjectCard: reads via appState.agent.* and appState.project.tab.* - TerminalTabs: state in appState.project.terminals.* - FileBrowser: state in appState.project.files.* - CommsTab: state in appState.project.comms.* - TaskBoardTab: state in appState.project.tasks.* All follow Rule 57 (no $derived with new objects) and Rule 58 (state tree architecture, components are pure renderers).
This commit is contained in:
parent
ae4c07c160
commit
162b5417e4
9 changed files with 870 additions and 400 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
190
ui-electrobun/src/mainview/app-state.svelte.ts
Normal file
190
ui-electrobun/src/mainview/app-state.svelte.ts
Normal 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';
|
||||
247
ui-electrobun/src/mainview/project-state.svelte.ts
Normal file
247
ui-electrobun/src/mainview/project-state.svelte.ts
Normal 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();
|
||||
}
|
||||
154
ui-electrobun/src/mainview/project-state.types.ts
Normal file
154
ui-electrobun/src/mainview/project-state.types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue