1
0
forked from wrenn/wrenn

Add terminal tab to capsule detail page and fix envd process lookup bugs

- Add multi-session Terminal tab with xterm.js (session tabs, close, reconnect)
- Keep terminal mounted across tab switches to preserve sessions
- Persist active tab in URL (?tab=terminal) so refresh stays on terminal
- Buffer keystrokes (50ms) to reduce per-character RPC overhead
- Add WebSocket auth via ?token= query param for browser WS connections
- Enable ws:true in Vite dev proxy for WebSocket support

envd fixes (pre-existing bugs exposed by multi-session terminals):
- Fix getProcess tag Range: inverted return values caused early stop when
  multiple tagged processes existed, making SendInput fail with "not found"
- Fix multiplexer deadlock: blocking send to cancelled fork's unbuffered
  channel prevented process cleanup. Now uses buffered channels (cap 64)
  with non-blocking fallback
This commit is contained in:
2026-04-11 04:27:16 +06:00
parent ab3fc4a807
commit 4b2ff279f7
8 changed files with 635 additions and 14 deletions

View File

@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { getCapsule, type Capsule } from '$lib/api/capsules';
import FilesTab from '$lib/components/FilesTab.svelte';
import TerminalTab from '$lib/components/TerminalTab.svelte';
import {
fetchSandboxMetrics,
METRIC_RANGES,
@ -18,9 +19,21 @@
let capsuleLoading = $state(true);
let capsuleError = $state<string | null>(null);
type Tab = 'metrics' | 'files';
type Tab = 'metrics' | 'files' | 'terminal';
const VALID_TABS: Tab[] = ['metrics', 'files', 'terminal'];
let activeTab = $state<Tab>('metrics');
function setTab(tab: Tab) {
activeTab = tab;
const url = new URL(window.location.href);
if (tab === 'metrics') {
url.searchParams.delete('tab');
} else {
url.searchParams.set('tab', tab);
}
history.replaceState(null, '', url.toString());
}
let range = $state<MetricRange>('10m');
let points = $state<MetricPoint[]>([]);
let metricsLoading = $state(true);
@ -304,7 +317,14 @@
});
onMount(async () => {
const urlRange = new URLSearchParams(window.location.search).get('range');
const params = new URLSearchParams(window.location.search);
const urlTab = params.get('tab') as Tab | null;
if (urlTab && VALID_TABS.includes(urlTab)) {
activeTab = urlTab;
}
const urlRange = params.get('range');
if (urlRange && METRIC_RANGES.includes(urlRange as MetricRange)) {
range = urlRange as MetricRange;
}
@ -407,7 +427,7 @@
<!-- Tabs (matches Templates page pattern) -->
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
<button
onclick={() => (activeTab = 'metrics')}
onclick={() => setTab('metrics')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'metrics'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
@ -420,7 +440,7 @@
</button>
<button
onclick={() => (activeTab = 'files')}
onclick={() => setTab('files')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'files'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
@ -431,9 +451,26 @@
</svg>
Files
</button>
<button
onclick={() => setTab('terminal')}
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
{activeTab === 'terminal'
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
</svg>
Terminal
</button>
</div>
<!-- Stats tab content -->
<!-- Tab content -->
<!-- Terminal stays mounted so sessions survive tab switches -->
<div class="flex flex-1 min-h-0" style:display={activeTab === 'terminal' ? 'flex' : 'none'}>
<TerminalTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} visible={activeTab === 'terminal'} />
</div>
{#if activeTab === 'files'}
<div class="anim-in flex flex-1 min-h-0" style="animation-delay: 0.05s">
<FilesTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} />