diff --git a/package.json b/package.json
index ccb1b13..f058086 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codbash-app",
- "version": "7.7.0",
+ "version": "7.8.0",
"description": "Dashboard + CLI for AI coding agents — Claude Code, Codex, Cursor, OpenCode, Kiro. View, search, resume, convert, sync sessions.",
"bin": {
"codbash": "./bin/cli.js",
diff --git a/src/changelog.js b/src/changelog.js
index be92d5e..03c7b03 100644
--- a/src/changelog.js
+++ b/src/changelog.js
@@ -1,6 +1,17 @@
'use strict';
const CHANGELOG = [
+ {
+ version: '7.8.0',
+ date: '2026-07-20',
+ title: 'Workspace — iTerm-like tabs & splits',
+ changes: [
+ 'Workspace now has named tabs (double-click to rename) — organize your terminals like in iTerm',
+ 'Split each tab into 1–4 panes; every pane is an independent shell',
+ 'Per-pane "Launch" dropdown types an agent command (Claude Code, Codex, Qwen, OpenCode, Kiro, Gemini) straight into that pane',
+ 'Background tabs stay alive when you switch away — their shells keep running',
+ ],
+ },
{
version: '7.7.0',
date: '2026-07-19',
diff --git a/src/frontend/styles.css b/src/frontend/styles.css
index 432a69c..06f01bc 100644
--- a/src/frontend/styles.css
+++ b/src/frontend/styles.css
@@ -2763,14 +2763,118 @@ body {
background: var(--bg-secondary);
}
.workspace-title { font-weight: 600; color: var(--text-primary); font-size: 13px; }
-.workspace-status { font-size: 12px; color: var(--text-muted); flex: 1; }
-.workspace-term {
+.ws-layouts { display: flex; gap: 4px; }
+.ws-layouts .toolbar-btn { min-width: 34px; letter-spacing: 1px; }
+.workspace-hint { font-size: 11px; color: var(--text-muted); }
+
+/* ── Tab bar (iTerm-like) ── */
+.ws-tabbar {
+ display: flex;
+ align-items: stretch;
+ gap: 2px;
+ padding: 4px 8px 0;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ overflow-x: auto;
+}
+.ws-tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ max-width: 200px;
+ border: 1px solid var(--border);
+ border-bottom: none;
+ border-radius: 8px 8px 0 0;
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ font-size: 12px;
+ cursor: pointer;
+ white-space: nowrap;
+ user-select: none;
+}
+.ws-tab.active { background: var(--bg-card); color: var(--text-primary); border-color: var(--accent-blue); }
+.ws-tab-name { overflow: hidden; text-overflow: ellipsis; }
+.ws-tab-close {
+ font-size: 14px; line-height: 1;
+ background: transparent; border: none; color: var(--text-muted);
+ cursor: pointer; padding: 0 2px; border-radius: 4px;
+}
+.ws-tab-close:hover { color: var(--accent-red); }
+.ws-tab-add {
+ align-self: center;
+ margin-left: 4px;
+ width: 26px; height: 26px;
+ font-size: 16px; line-height: 1;
+ background: transparent; border: 1px solid var(--border);
+ color: var(--text-secondary); border-radius: 6px; cursor: pointer;
+}
+.ws-tab-add:hover { color: var(--text-primary); border-color: var(--text-muted); }
+.ws-tab-rename {
+ font-size: 12px; width: 110px;
+ background: var(--bg-input); color: var(--text-primary);
+ border: 1px solid var(--accent-blue); border-radius: 4px; padding: 2px 4px;
+}
+
+.workspace-tabpanes { flex: 1; min-height: 0; display: flex; }
+
+/* Grid of terminal panes. 1–3 panes = equal columns; 4 = 2×2. Only the active
+ tab's grid is shown; hidden grids keep their ptys alive. */
+.workspace-grid {
flex: 1;
min-height: 0;
- padding: 6px 8px;
- background: #08090c;
+ display: none;
+ grid-gap: 6px;
+ gap: 6px;
+ padding: 6px;
+ background: var(--bg-primary);
+}
+.workspace-grid.active { display: grid; }
+.workspace-grid[data-count="1"] { grid-template-columns: 1fr; }
+.workspace-grid[data-count="2"] { grid-template-columns: 1fr 1fr; }
+.workspace-grid[data-count="3"] { grid-template-columns: 1fr 1fr 1fr; }
+.workspace-grid[data-count="4"] { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
+
+.ws-pane {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
overflow: hidden;
+ background: #08090c;
+}
+.ws-pane-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 6px 4px 10px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+}
+.ws-pane-status { font-size: 11px; color: var(--text-muted); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ws-pane-launch {
+ font-size: 11px;
+ background: var(--bg-input);
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 3px 6px;
+ cursor: pointer;
+}
+.ws-pane-close {
+ font-size: 15px;
+ line-height: 1;
+ width: 22px; height: 22px;
+ background: transparent;
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ cursor: pointer;
}
+.ws-pane-close:hover { color: var(--accent-red); border-color: var(--accent-red); }
+.ws-pane-term { flex: 1; min-height: 0; padding: 4px 6px; overflow: hidden; }
.changelog-container { padding: 20px; max-width: 700px; }
diff --git a/src/frontend/workspace.js b/src/frontend/workspace.js
index 0d5d9b0..15d7d78 100644
--- a/src/frontend/workspace.js
+++ b/src/frontend/workspace.js
@@ -1,35 +1,49 @@
-// Workspace — browser terminal (B-0 walking skeleton).
+// Workspace — iTerm-like browser terminal: named tabs, each with 1–4 split
+// panes (B-3). Every pane is an independent xterm + WebSocket + server-side pty.
//
-// Lazily loads the vendored xterm.js bundle (so the base dashboard never pays
-// for it), fetches the per-process WS token from /api/terminal/status, then
-// opens a single pty over a WebSocket to /ws/terminal.
+// Model:
+// _wsTabs = [{ id, name, panes: [pane] }] pane = { id, cmd, term, sock, fit, ro }
+// _wsActiveTabId the visible tab
+// Each tab owns a
; only the active one is
+// shown (display:grid), the rest stay in the DOM hidden so their ptys live on
+// across tab switches. Layout is a CSS grid of 1–4 panes.
//
// Wire protocol (mirror of src/terminal.js):
// • binary frames = raw terminal bytes (stdin up / stdout down)
// • text frames = JSON control ({t:'resize'|'ready'|'exit'|'error'})
-var _wsTerm = null; // xterm Terminal instance
-var _wsSock = null; // WebSocket
-var _wsFit = null; // FitAddon
-var _wsResizeHandler = null;
+var MAX_WS_PANES = 4;
+
+var WORKSPACE_AGENTS = [
+ { label: 'Claude Code', cmd: 'claude' },
+ { label: 'Codex', cmd: 'codex' },
+ { label: 'Qwen', cmd: 'qwen' },
+ { label: 'OpenCode', cmd: 'opencode' },
+ { label: 'Kiro', cmd: 'kiro-cli' },
+ { label: 'Gemini', cmd: 'gemini' }
+];
+
+var _wsTabs = [];
+var _wsActiveTabId = null;
+var _wsTabSeq = 0;
+var _wsPaneSeq = 0;
+var _wsToken = null;
var _wsVendorLoaded = false;
+// ── vendor ──────────────────────────────────────────────────────────────────
function _loadWorkspaceVendor() {
if (_wsVendorLoaded) return Promise.resolve();
return new Promise(function (resolve, reject) {
- // CSS
if (!document.querySelector('link[data-xterm]')) {
var link = document.createElement('link');
- link.rel = 'stylesheet';
- link.href = '/vendor/xterm.css';
+ link.rel = 'stylesheet'; link.href = '/vendor/xterm.css';
link.setAttribute('data-xterm', '1');
document.head.appendChild(link);
}
function loadScript(src) {
return new Promise(function (res, rej) {
var s = document.createElement('script');
- s.src = src;
- s.onload = res;
+ s.src = src; s.onload = res;
s.onerror = function () { rej(new Error('failed to load ' + src)); };
document.head.appendChild(s);
});
@@ -41,131 +55,321 @@ function _loadWorkspaceVendor() {
});
}
-// Called from render() when leaving the Workspace view.
-function teardownWorkspaceIfActive() {
- if (_wsSock || _wsTerm) _teardownWorkspace();
-}
-
-function _teardownWorkspace() {
- if (_wsResizeHandler) { window.removeEventListener('resize', _wsResizeHandler); _wsResizeHandler = null; }
- if (_wsSock) {
- try { _wsSock.onclose = null; _wsSock.close(); } catch (e) {}
- _wsSock = null;
- }
- if (_wsTerm) {
- try { _wsTerm.dispose(); } catch (e) {}
- _wsTerm = null;
+// ── lookups ─────────────────────────────────────────────────────────────────
+function _wsTab(id) { return _wsTabs.find(function (t) { return t.id === id; }); }
+function _wsActiveTab() { return _wsTab(_wsActiveTabId); }
+function _wsFindPane(id) {
+ for (var i = 0; i < _wsTabs.length; i++) {
+ var p = _wsTabs[i].panes.find(function (x) { return x.id === id; });
+ if (p) return p;
}
- _wsFit = null;
+ return null;
}
-async function renderWorkspace(container) {
- _teardownWorkspace();
- container.innerHTML = 'Loading terminal…
';
-
- var status;
- try {
- var resp = await fetch('/api/terminal/status');
- status = await resp.json();
- } catch (e) {
- container.innerHTML = 'Could not reach the terminal service.
';
- return;
- }
-
- if (!status.available) {
- container.innerHTML =
- '' +
- 'Terminal not available
' +
- escHtml(status.hint || 'The optional terminal dependency is not installed.') +
- (status.error ? '
' + escHtml(status.error) + '' : '') +
- '
';
- return;
- }
-
- container.innerHTML =
- '' +
- '
' +
- 'Workspace' +
- 'connecting…' +
- '' +
- '
' +
- '
' +
- '
';
+// ── teardown ────────────────────────────────────────────────────────────────
+function _wsTeardownPane(pane) {
+ if (!pane) return;
+ if (pane.ro) { try { pane.ro.disconnect(); } catch (e) {} }
+ if (pane.sock) { try { pane.sock.onclose = null; pane.sock.close(); } catch (e) {} }
+ if (pane.term) { try { pane.term.dispose(); } catch (e) {} }
+ pane.sock = null; pane.term = null; pane.fit = null; pane.ro = null;
+}
+function teardownWorkspaceIfActive() {
+ if (!_wsTabs.length) return;
+ _wsTabs.forEach(function (t) { t.panes.forEach(_wsTeardownPane); });
+ _wsTabs = []; _wsActiveTabId = null;
+}
- try {
- await _loadWorkspaceVendor();
- } catch (e) {
- container.innerHTML = 'Failed to load terminal assets.
';
- return;
- }
+// ── pane connection ─────────────────────────────────────────────────────────
+function _wsShortCwd(cwd) {
+ if (!cwd) return 'ready';
+ return cwd.replace(/^\/Users\/[^/]+/, '~').replace(/^\/home\/[^/]+/, '~');
+}
- var host = document.getElementById('wsTerm');
- if (!host) return; // view switched away while loading
+function _wsConnectPane(pane) {
+ var host = document.getElementById('wsTermHost-' + pane.id);
+ if (!host) return;
var term = new Terminal({
- cursorBlink: true,
- fontSize: 13,
+ cursorBlink: true, fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
- theme: { background: '#08090c' },
- scrollback: 5000,
- allowProposedApi: true
+ theme: { background: '#08090c' }, scrollback: 5000, allowProposedApi: true
});
var fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(host);
try { fit.fit(); } catch (e) {}
- _wsTerm = term;
- _wsFit = fit;
+ pane.term = term; pane.fit = fit;
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/ws/terminal' +
- '?token=' + encodeURIComponent(status.token) +
- '&cols=' + term.cols + '&rows=' + term.rows;
+ '?token=' + encodeURIComponent(_wsToken) + '&cols=' + term.cols + '&rows=' + term.rows;
var sock = new WebSocket(url);
sock.binaryType = 'arraybuffer';
- _wsSock = sock;
-
- var statusEl = document.getElementById('wsStatus');
- function setStatus(txt) { if (statusEl) statusEl.textContent = txt; }
+ pane.sock = sock;
var enc = new TextEncoder();
- var dec = new TextDecoder();
+ function setStatus(txt) { var el = document.getElementById('wsStatus-' + pane.id); if (el) el.textContent = txt; }
- sock.onopen = function () {
- setStatus('connected');
- term.focus();
- };
+ sock.onopen = function () { setStatus('connected'); };
sock.onmessage = function (ev) {
if (typeof ev.data === 'string') {
- var msg;
- try { msg = JSON.parse(ev.data); } catch (e) { return; }
- if (msg.t === 'ready') { setStatus(msg.cwd || 'ready'); }
- else if (msg.t === 'exit') { setStatus('exited (' + msg.code + ')'); }
+ var msg; try { msg = JSON.parse(ev.data); } catch (e) { return; }
+ if (msg.t === 'ready') {
+ setStatus(_wsShortCwd(msg.cwd));
+ if (pane.cmd) setTimeout(function () { if (sock.readyState === 1) sock.send(enc.encode(pane.cmd + '\r')); }, 120);
+ } else if (msg.t === 'exit') { setStatus('exited (' + msg.code + ')'); }
else if (msg.t === 'error') { setStatus('error'); term.write('\r\n\x1b[31m' + (msg.message || 'error') + '\x1b[0m\r\n'); }
return;
}
- // binary: raw terminal bytes
term.write(new Uint8Array(ev.data));
};
sock.onclose = function () { setStatus('disconnected'); };
sock.onerror = function () { setStatus('connection error'); };
- // stdin -> server (binary)
- term.onData(function (data) {
- if (sock.readyState === WebSocket.OPEN) sock.send(enc.encode(data));
+ term.onData(function (data) { if (sock.readyState === 1) sock.send(enc.encode(data)); });
+
+ var rt = null;
+ pane.ro = new ResizeObserver(function () {
+ clearTimeout(rt);
+ rt = setTimeout(function () {
+ try { fit.fit(); } catch (e) {}
+ if (sock.readyState === 1) sock.send(JSON.stringify({ t: 'resize', cols: term.cols, rows: term.rows }));
+ }, 100);
+ });
+ try { pane.ro.observe(host.parentNode); } catch (e) {}
+}
+
+// ── markup ──────────────────────────────────────────────────────────────────
+function _wsPaneMarkup(pane) {
+ var opts = '';
+ WORKSPACE_AGENTS.forEach(function (a) { opts += ''; });
+ return '' +
+ '' +
+ '
' +
+ 'connecting…' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
';
+}
+
+function _wsTabMarkup(tab) {
+ return '' +
+ '' +
+ '' + escHtml(tab.name) + '' +
+ '' +
+ '
';
+}
+
+// ── tab bar + active-tab rendering ──────────────────────────────────────────
+function _wsRenderTabbar() {
+ var bar = document.getElementById('wsTabbar');
+ if (!bar) return;
+ bar.innerHTML = _wsTabs.map(_wsTabMarkup).join('') +
+ '';
+}
+
+function _wsRefitTab(tab) {
+ if (!tab) return;
+ tab.panes.forEach(function (p) {
+ if (p.fit) { try { p.fit.fit(); } catch (e) {} }
+ if (p.sock && p.sock.readyState === 1 && p.term) {
+ p.sock.send(JSON.stringify({ t: 'resize', cols: p.term.cols, rows: p.term.rows }));
+ }
+ });
+}
+
+// Ensure every tab has a grid div; reconcile the active tab's panes; show only
+// the active grid. Live panes are never rebuilt — only new panes get connected.
+function _wsRenderPanes() {
+ var area = document.getElementById('wsTabPanes');
+ if (!area) return;
+
+ // Remove grids for tabs that no longer exist.
+ var tabIds = {};
+ _wsTabs.forEach(function (t) { tabIds[t.id] = true; });
+ Array.prototype.slice.call(area.querySelectorAll('.workspace-grid')).forEach(function (g) {
+ if (!tabIds[g.getAttribute('data-tab-id')]) g.remove();
});
- // resize -> server (text control), debounced
- var resizeTimer = null;
- function doResize() {
- try { fit.fit(); } catch (e) {}
- if (sock.readyState === WebSocket.OPEN) {
- sock.send(JSON.stringify({ t: 'resize', cols: term.cols, rows: term.rows }));
+ _wsTabs.forEach(function (tab) {
+ var grid = area.querySelector('.workspace-grid[data-tab-id="' + tab.id + '"]');
+ if (!grid) {
+ area.insertAdjacentHTML('beforeend', '');
+ grid = area.querySelector('.workspace-grid[data-tab-id="' + tab.id + '"]');
}
+ var active = tab.id === _wsActiveTabId;
+ grid.classList.toggle('active', active);
+ grid.setAttribute('data-count', String(tab.panes.length));
+
+ // Reconcile panes within this tab's grid.
+ var present = {};
+ tab.panes.forEach(function (p) { present[p.id] = true; });
+ Array.prototype.slice.call(grid.querySelectorAll('.ws-pane')).forEach(function (el) {
+ if (!present[el.getAttribute('data-pane-id')]) el.remove();
+ });
+ tab.panes.forEach(function (p) {
+ if (!grid.querySelector('.ws-pane[data-pane-id="' + p.id + '"]')) {
+ grid.insertAdjacentHTML('beforeend', _wsPaneMarkup(p));
+ _wsConnectPane(p);
+ }
+ });
+ });
+
+ // Refit the active tab shortly after it becomes visible (0-size while hidden).
+ setTimeout(function () { _wsRefitTab(_wsActiveTab()); }, 60);
+}
+
+function _wsSyncLayoutButtons() {
+ var tab = _wsActiveTab();
+ var n = tab ? tab.panes.length : 0;
+ ['1', '2', '3', '4'].forEach(function (k) {
+ var b = document.getElementById('wsLayout-' + k);
+ if (b) b.classList.toggle('active', n === Number(k));
+ });
+ var add = document.getElementById('wsAddPane');
+ if (add) add.disabled = n >= MAX_WS_PANES;
+}
+
+function _wsRenderAll() { _wsRenderTabbar(); _wsRenderPanes(); _wsSyncLayoutButtons(); }
+
+// ── tab ops ─────────────────────────────────────────────────────────────────
+function addWorkspaceTab() {
+ var tab = { id: 't' + (++_wsTabSeq), name: 'Tab ' + (_wsTabs.length + 1), panes: [{ id: 'p' + (++_wsPaneSeq), cmd: null }] };
+ _wsTabs.push(tab);
+ _wsActiveTabId = tab.id;
+ _wsRenderAll();
+}
+
+function activateWorkspaceTab(id) {
+ if (id === _wsActiveTabId) return;
+ _wsActiveTabId = id;
+ _wsRenderAll();
+ var tab = _wsActiveTab();
+ if (tab && tab.panes[0] && tab.panes[0].term) tab.panes[0].term.focus();
+}
+
+function closeWorkspaceTab(id) {
+ var idx = _wsTabs.findIndex(function (t) { return t.id === id; });
+ if (idx < 0) return;
+ _wsTabs[idx].panes.forEach(_wsTeardownPane);
+ _wsTabs.splice(idx, 1);
+ if (_wsTabs.length === 0) { addWorkspaceTab(); return; }
+ if (_wsActiveTabId === id) _wsActiveTabId = _wsTabs[Math.max(0, idx - 1)].id;
+ _wsRenderAll();
+}
+
+function renameWorkspaceTab(id) {
+ var tab = _wsTab(id);
+ if (!tab) return;
+ var el = document.querySelector('.ws-tab[data-tab-id="' + id + '"] .ws-tab-name');
+ if (!el) return;
+ var input = document.createElement('input');
+ input.className = 'ws-tab-rename';
+ input.value = tab.name;
+ el.replaceWith(input);
+ input.focus(); input.select();
+ function commit() {
+ var v = input.value.trim();
+ if (v) tab.name = v;
+ _wsRenderTabbar();
}
- _wsResizeHandler = function () {
- clearTimeout(resizeTimer);
- resizeTimer = setTimeout(doResize, 120);
- };
- window.addEventListener('resize', _wsResizeHandler);
+ input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); commit(); }
+ else if (e.key === 'Escape') { _wsRenderTabbar(); }
+ });
+ input.addEventListener('blur', commit);
+ input.addEventListener('click', function (e) { e.stopPropagation(); });
+}
+
+// ── pane ops (operate on the active tab) ────────────────────────────────────
+function setWorkspaceLayout(n) {
+ var tab = _wsActiveTab();
+ if (!tab) return;
+ n = Math.max(1, Math.min(MAX_WS_PANES, n));
+ while (tab.panes.length < n) tab.panes.push({ id: 'p' + (++_wsPaneSeq), cmd: null });
+ while (tab.panes.length > n) _wsTeardownPane(tab.panes.pop());
+ _wsRenderPanes(); _wsSyncLayoutButtons();
+}
+
+function addWorkspacePane(cmd) {
+ var tab = _wsActiveTab();
+ if (!tab || tab.panes.length >= MAX_WS_PANES) return;
+ tab.panes.push({ id: 'p' + (++_wsPaneSeq), cmd: cmd || null });
+ _wsRenderPanes(); _wsSyncLayoutButtons();
+}
+
+function closeWorkspacePane(id) {
+ for (var i = 0; i < _wsTabs.length; i++) {
+ var tab = _wsTabs[i];
+ var idx = tab.panes.findIndex(function (p) { return p.id === id; });
+ if (idx < 0) continue;
+ _wsTeardownPane(tab.panes[idx]);
+ tab.panes.splice(idx, 1);
+ if (tab.panes.length === 0) tab.panes.push({ id: 'p' + (++_wsPaneSeq), cmd: null });
+ _wsRenderPanes(); _wsSyncLayoutButtons();
+ return;
+ }
+}
+
+function launchAgentInPane(id, cmd) {
+ if (!cmd) return;
+ var pane = _wsFindPane(id);
+ if (!pane || !pane.sock || pane.sock.readyState !== 1) return;
+ pane.sock.send(new TextEncoder().encode(cmd + '\r'));
+ if (pane.term) pane.term.focus();
+}
+
+// ── mount ───────────────────────────────────────────────────────────────────
+async function renderWorkspace(container) {
+ // Idempotent: background refreshes call render() while on this view.
+ if (_wsTabs.length && document.getElementById('wsTabPanes')) return;
+
+ teardownWorkspaceIfActive();
+ container.innerHTML = 'Loading terminal…
';
+
+ var status;
+ try { status = await (await fetch('/api/terminal/status')).json(); }
+ catch (e) { container.innerHTML = 'Could not reach the terminal service.
'; return; }
+
+ if (!status.available) {
+ container.innerHTML =
+ '' +
+ 'Terminal not available
' +
+ escHtml(status.hint || 'The optional terminal dependency is not installed.') +
+ (status.error ? '
' + escHtml(status.error) + '' : '') +
+ '
';
+ return;
+ }
+ _wsToken = status.token;
+
+ container.innerHTML =
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
Double-click a tab to rename' +
+ '
' +
+ '
' +
+ '
';
+
+ try { await _loadWorkspaceVendor(); }
+ catch (e) { container.innerHTML = 'Failed to load terminal assets.
'; return; }
+ if (!document.getElementById('wsTabPanes')) return; // switched away while loading
+
+ _wsTabs = [{ id: 't' + (++_wsTabSeq), name: 'Tab 1', panes: [{ id: 'p' + (++_wsPaneSeq), cmd: null }] }];
+ _wsActiveTabId = _wsTabs[0].id;
+ _wsRenderAll();
}