fix(electrobun): address all 20 Codex review findings

CRITICAL:
- PTY leak: Terminal.svelte now calls pty.close on destroy, not just unsubscribe
- Agent session cleanup: clearSession() removes done/error sessions, backend
  deletes after 60s grace period

HIGH:
- Clone branch passthrough: user's branch name flows through callback
- Circular imports: extracted rpc.ts singleton, broke main.ts ↔ App.svelte cycle
- Settings wired to runtime: Terminal reads cursor/scrollback from settings
- Security disclaimer: added "prototype — not system keyring" notice
- ThemeEditor: fixed basePalette → initialPalette reference

MEDIUM:
- Clone race: UUID suffix instead of count-based index
- Silent failures: structured error returns from PTY handlers
- WebKitGTK mount: only current + previous group mounted
- Debug listeners: gated behind DEBUG, cleanup on destroy
- NDJSON residual buffer parsed on process exit
- Codex adapter: deduplicated tool_call/tool_result
- extraEnv: rejects CLAUDE*/CODEX*/OLLAMA* keys
- settings-db: runMigrations() with version tracking
- active_group: persisted via settings.set

LOW:
- Removed dead demo code, unused variables
- color-mix() fallbacks added
This commit is contained in:
Hibryda 2026-03-22 01:20:23 +01:00
parent ef0183de7f
commit 29a3370e79
18 changed files with 331 additions and 114 deletions

View file

@ -8,7 +8,7 @@
import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
import { appRpc } from './main.ts';
import { appRpc } from './rpc.ts';
// ── Types ─────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
@ -104,6 +104,9 @@
{ id: 'research', name: 'Research', icon: '🔬', position: 3 },
]);
let activeGroupId = $state('dev');
// Fix #10: Track previous group to limit mounted DOM (max 2 groups)
let previousGroupId = $state<string | null>(null);
let mountedGroupIds = $derived(new Set([activeGroupId, ...(previousGroupId ? [previousGroupId] : [])]));
// ── Filtered projects for active group ────────────────────────
let activeGroup = $derived(groups.find(g => g.id === activeGroupId) ?? groups[0]);
@ -111,39 +114,15 @@
PROJECTS.filter(p => (p.groupId ?? 'dev') === activeGroupId)
);
// Group projects into: top-level cards + clone groups
interface ProjectRow { type: 'standalone'; project: Project; }
interface CloneGroupRow { type: 'clone-group'; parent: Project; clones: Project[]; }
type GridRow = ProjectRow | CloneGroupRow;
let gridRows = $derived((): GridRow[] => {
const standalone: GridRow[] = [];
const cloneParentIds = new Set(
filteredProjects.filter(p => p.cloneOf).map(p => p.cloneOf!)
);
for (const p of filteredProjects) {
if (p.cloneOf) continue;
if (cloneParentIds.has(p.id)) {
const clones = filteredProjects
.filter(c => c.cloneOf === p.id)
.sort((a, b) => (a.cloneIndex ?? 0) - (b.cloneIndex ?? 0));
standalone.push({ type: 'clone-group', parent: p, clones });
} else {
standalone.push({ type: 'standalone', project: p });
}
}
return standalone;
});
// ── Clone helpers ──────────────────────────────────────────────
function cloneCountForProject(projectId: string): number {
return PROJECTS.filter(p => p.cloneOf === projectId).length;
}
function handleClone(projectId: string) {
function handleClone(projectId: string, branch: string) {
const source = PROJECTS.find(p => p.id === projectId);
if (!source) return;
const branchName = `feature/clone-${Date.now()}`;
const branchName = branch || `feature/clone-${Date.now()}`;
appRpc.request["project.clone"]({ projectId, branchName }).then((result) => {
if (result.ok && result.project) {
const cloneConfig = JSON.parse(result.project.config) as Project;
@ -176,9 +155,13 @@
// ── setActiveGroup: fire-and-forget RPC ───────────────────────
function setActiveGroup(id: string | undefined) {
if (!id) return;
console.log('[DEBUG] setActiveGroup:', id);
// Fix #10: Track previous group for DOM mount limit
if (activeGroupId !== id) {
previousGroupId = activeGroupId;
}
activeGroupId = id;
// NO RPC — pure local state change for debugging
// Fix #16: Persist active_group selection
appRpc.request["settings.set"]({ key: 'active_group', value: id }).catch(console.error);
}
// ── Window controls ────────────────────────────────────────────
@ -263,10 +246,12 @@
function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
// ── DEBUG: Visual click diagnostics overlay ─────────────────────
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
let debugLog = $state<string[]>([]);
$effect(() => {
if (!DEBUG_ENABLED) return;
function debugClick(e: MouseEvent) {
const el = e.target as HTMLElement;
const tag = el?.tagName;
@ -276,15 +261,20 @@
if (elAtPoint && elAtPoint !== el) {
const overTag = (elAtPoint as HTMLElement).tagName;
const overCls = ((elAtPoint as HTMLElement).className?.toString?.() ?? '').slice(0, 40);
line += ` ⚠️OVERLAY: ${overTag}.${overCls}`;
line += ` OVERLAY: ${overTag}.${overCls}`;
}
debugLog = [...debugLog.slice(-8), line];
}
document.addEventListener('click', debugClick, true);
document.addEventListener('mousedown', (e) => {
function debugDown(e: MouseEvent) {
const el = e.target as HTMLElement;
debugLog = [...debugLog.slice(-8), `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? '').slice(0, 40)}`];
}, true);
}
document.addEventListener('click', debugClick, true);
document.addEventListener('mousedown', debugDown, true);
return () => {
document.removeEventListener('click', debugClick, true);
document.removeEventListener('mousedown', debugDown, true);
};
});
// ── Init ───────────────────────────────────────────────────────
@ -327,7 +317,6 @@
<div
class="app-shell"
role="presentation"
onresize={saveWindowFrame}
>
<!-- Left sidebar icon rail -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
@ -374,9 +363,10 @@
<main class="workspace">
<!-- Project grid -->
<div class="project-grid" role="list" aria-label="{activeGroup?.name ?? 'Projects'} projects">
<!-- ALL projects rendered always with display:none/flex toggling.
<!-- Fix #10: Only mount projects in active + previous group (max 2 groups).
WebKitGTK corrupts hit-test tree when DOM nodes are added/removed during click events. -->
{#each PROJECTS as project (project.id)}
{#if mountedGroupIds.has(project.groupId ?? 'dev')}
<div role="listitem" style:display={(project.groupId ?? 'dev') === activeGroupId ? 'flex' : 'none'}>
<ProjectCard
id={project.id}
@ -395,6 +385,7 @@
cloneOf={project.cloneOf}
/>
</div>
{/if}
{/each}
<!-- Empty group — always in DOM, visibility toggled -->
@ -486,12 +477,14 @@
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer>
<!-- DEBUG: visible click log -->
{#if DEBUG_ENABLED && debugLog.length > 0}
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
<div style="position:fixed;bottom:0;left:0;right:0;background:#000;color:#0f0;font-family:monospace;font-size:10px;padding:4px 8px;z-index:9999;max-height:100px;overflow-y:auto;pointer-events:none;">
{#each debugLog as line}
<div>{line}</div>
{/each}
</div>
{/if}
<style>
:global(body) { overflow: hidden; }
@ -642,28 +635,6 @@
.project-grid::-webkit-scrollbar-track { background: transparent; }
.project-grid::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
.clone-group-row {
grid-column: 1 / -1;
display: flex;
flex-direction: row;
gap: 0;
align-items: stretch;
min-height: 0;
}
.clone-group-row :global(.project-card) { flex: 1; min-width: 0; }
.chain-icon {
flex-shrink: 0;
width: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-surface1);
}
.chain-icon svg { width: 1rem; height: 1rem; }
.empty-group {
grid-column: 1 / -1;
display: flex;