agent-orchestrator/ui-electrobun/src/mainview/TerminalTabs.svelte
Hibryda 162b5417e4 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).
2026-03-24 15:20:09 +01:00

223 lines
5.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import Terminal from './Terminal.svelte';
import { appState } from './app-state.svelte.ts';
interface Props {
projectId: string;
accent?: string;
/** Project working directory — passed to terminal shells. */
cwd?: string;
}
let { projectId, accent = 'var(--ctp-mauve)', cwd }: Props = $props();
// Read terminal state from project state tree
function getTerminals() { return appState.project.getState(projectId).terminals; }
function blurTerminal() {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
function addTab() {
blurTerminal();
appState.project.terminals.addTab(projectId);
}
function closeTab(id: string, e: MouseEvent) {
e.stopPropagation();
blurTerminal();
appState.project.terminals.closeTab(projectId, id);
}
function activateTab(id: string) {
blurTerminal();
appState.project.terminals.activateTab(projectId, id);
}
function toggleExpand() {
blurTerminal();
appState.project.terminals.toggleExpanded(projectId);
}
</script>
<!-- Wrapper: uses flex to push tab bar to bottom when terminal is collapsed -->
<div class="term-wrapper" style="--accent: {accent}">
<!-- Tab bar: always visible, acts as divider -->
<div class="term-bar" role="toolbar" aria-label="Terminal tabs" tabindex="-1" onmousedown={blurTerminal}>
<button
class="expand-btn"
onclick={toggleExpand}
title={getTerminals().expanded ? 'Collapse terminal' : 'Expand terminal'}
>
<svg
class="chevron"
class:open={getTerminals().expanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 6 15 12 9 18"/>
</svg>
</button>
<div class="term-tabs" role="tablist">
{#each getTerminals().tabs as tab (tab.id)}
<!-- div+role="tab" allows a nested <button> for the close action -->
<div
class="term-tab"
class:active={getTerminals().activeTabId === tab.id}
role="tab"
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 getTerminals().tabs.length > 1}
<button
class="tab-close"
aria-label="Close {tab.title}"
onclick={(e) => closeTab(tab.id, e)}
>×</button>
{/if}
</div>
{/each}
<button class="tab-add" onclick={() => addTab()}>+</button>
</div>
</div>
<!-- Terminal panes: always in DOM, display toggled.
WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
<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}
{/each}
</div>
</div>
<style>
.term-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* Tab bar */
.term-bar {
display: flex;
align-items: stretch;
height: 1.75rem;
background: var(--ctp-mantle);
border-top: 1px solid var(--ctp-surface0);
flex-shrink: 0;
z-index: 5;
}
.expand-btn {
width: 1.75rem;
flex-shrink: 0;
background: transparent;
border: none;
border-right: 1px solid var(--ctp-surface0);
color: var(--ctp-overlay1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.expand-btn:hover { color: var(--ctp-text); }
.expand-btn svg { width: 0.75rem; height: 0.75rem; transition: transform 0.15s; }
.chevron.open { transform: rotate(90deg); }
.term-tabs {
display: flex;
align-items: stretch;
flex: 1;
overflow-x: auto;
scrollbar-width: none;
gap: 0.125rem;
padding: 0 0.25rem;
}
.term-tabs::-webkit-scrollbar { display: none; }
.term-tab {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.5rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
margin-bottom: -1px;
font-family: inherit;
}
.term-tab:hover { color: var(--ctp-text); }
.term-tab.active { color: var(--ctp-text); border-bottom-color: var(--accent); }
.tab-label { pointer-events: none; }
.tab-close {
width: 0.875rem;
height: 0.875rem;
border-radius: 0.2rem;
font-size: 0.6875rem;
color: var(--ctp-overlay0);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
/* Reset <button> defaults */
background: transparent;
border: none;
padding: 0;
font-family: inherit;
line-height: 1;
}
.tab-close:hover { background: var(--ctp-surface1); color: var(--ctp-red); }
.tab-add {
align-self: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
background: transparent;
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
color: var(--ctp-overlay1);
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.125rem;
}
.tab-add:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
/* Terminal panes — fill remaining space below tab bar */
.term-panes {
flex: 1;
min-height: 8rem;
position: relative;
}
.term-pane {
position: absolute;
inset: 0;
flex-direction: column;
}
</style>