mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
feat(tabs): grouped vertical rail from owner tab layouts
The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout) and draws its groups as collapsible sections. This is the first frontend consumer of the tab-layout backend and it is read-only: nothing in the browser writes the layout yet. - tab-layout-browser.js (new, pure, loaded before app.js): projects the layout onto the live sessions and open web tabs, renders the grouped markup, stores collapse per device, and sequences loads newest-wins with a bounded retry on failure. - app.js: loads the layout on init and on tab:layoutChanged, renders the grouped rail from the same per-row markup the flat rail uses, falls through to a full render whenever the grouping structure changes, and withholds drag-reorder in the grouped rail. - Grouping is opt-in by construction. With no layout, a failed read, a layout without groups, or a horizontal strip, the rail renders exactly as before (byte-identical markup). - Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the palette keep reading the server-projected order, and row badges keep their Alt+N slot. - A collapsed group still shows the active row; lineage arcs to a hidden session anchor to its group header. - webview-tabs.js: renderWebviewTab() extracted so a single web tab can be placed into its group with unchanged markup.
This commit is contained in:
+199
-10
@@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
// Owner tab layout (grouped vertical rail)
|
||||
[SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'],
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
|
||||
@@ -573,6 +575,12 @@ class CodemanApp {
|
||||
this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results
|
||||
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
||||
this.draggedTabId = null; // Currently dragged tab session ID
|
||||
// Owner tab layout (GET /api/tab-layout), read-only here: it only changes how
|
||||
// the vertical rail GROUPS rows. sessionOrder above stays the tab order.
|
||||
this.tabLayout = null;
|
||||
this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed
|
||||
this._hiddenTabGroupByRef = new Map(); // 'session:<id>' -> collapsed group id
|
||||
this._lastTabGroupStructureKey = null;
|
||||
this.cases = [];
|
||||
this.currentRun = null;
|
||||
this.totalTokens = 0;
|
||||
@@ -4266,6 +4274,10 @@ class CodemanApp {
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
|
||||
// (Re)read the owner tab layout on every init, including SSE reconnects: a
|
||||
// tab:layoutChanged sent while this client was disconnected is never replayed.
|
||||
this._loadTabLayout();
|
||||
|
||||
if (data.respawnStatus) {
|
||||
this.respawnStatus = data.respawnStatus;
|
||||
} else {
|
||||
@@ -5078,6 +5090,12 @@ class CodemanApp {
|
||||
_updateActiveTabImmediate(sessionId) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
// Grouped rail: selecting a session hidden in a collapsed group must show it
|
||||
// (and re-hide the previous exception), which a class toggle cannot do.
|
||||
if (this._isTabGroupStructureStale()) {
|
||||
this._fullRenderSessionTabs();
|
||||
return;
|
||||
}
|
||||
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.id === sessionId) {
|
||||
@@ -5237,7 +5255,12 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
// Grouped rail: a collapsed group keeps its rows out of the DOM, so compare
|
||||
// against the rows the projection SHOWS, not every live session.
|
||||
const groupProjection = this._projectTabGroups();
|
||||
const currentIds = groupProjection
|
||||
? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id))
|
||||
: new Set(this.sessions.keys());
|
||||
|
||||
// Web tabs live in the same strip but are not in this.sessions, so they need
|
||||
// their own change check. Without it, the session-only comparison below is
|
||||
@@ -5246,14 +5269,20 @@ class CodemanApp {
|
||||
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
|
||||
t => t.dataset.webviewId
|
||||
);
|
||||
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const wantedWebIds = groupProjection
|
||||
? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id)
|
||||
: (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const webTabsUnchanged =
|
||||
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
|
||||
|
||||
// Check if we can do incremental update (same session IDs and same web tabs)
|
||||
// The grouped rail's structure (sections, collapse, the shown exception) can
|
||||
// change while the id sets stay equal; the in-place patch below cannot move
|
||||
// or hide a row, so any structural change takes the full rebuild.
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged;
|
||||
webTabsUnchanged &&
|
||||
!this._isTabGroupStructureStale(groupProjection);
|
||||
|
||||
if (canIncremental) {
|
||||
// Read once for the whole pass, like the full-rebuild path: this touches
|
||||
@@ -5591,6 +5620,10 @@ class CodemanApp {
|
||||
// layout, and the tabs then carry no inline order at all — the header
|
||||
// strip's markup is byte-identical to before.
|
||||
const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id)));
|
||||
// One row per session, in tab order. The flat strip emits them as-is; the
|
||||
// grouped rail places the SAME markup into its sections, so a row never
|
||||
// differs between the two (badge = Alt+N slot in sessionOrder either way).
|
||||
const rowHtml = new Map();
|
||||
let _tabIdx = 0;
|
||||
for (const id of tabOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
@@ -5654,7 +5687,7 @@ class CodemanApp {
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -5677,12 +5710,36 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
const groupProjection = this._projectTabGroups();
|
||||
if (groupProjection) {
|
||||
// Grouped vertical rail. Web tabs keep their flat-strip Alt+N slot (after
|
||||
// every session), wherever their group puts them.
|
||||
const webviewSlots = new Map(
|
||||
(this.webviewOrder || []).filter((wid) => this.webviews?.has(wid)).map((wid, i) => [wid, _tabIdx + i])
|
||||
);
|
||||
parts.push(
|
||||
window.CodemanTabLayout.renderProjection(
|
||||
groupProjection,
|
||||
(ref) =>
|
||||
ref.kind === 'session'
|
||||
? rowHtml.get(ref.id) || ''
|
||||
: this.renderWebviewTab?.(ref.id, webviewSlots.get(ref.id) ?? Infinity) || '',
|
||||
escapeHtml
|
||||
)
|
||||
);
|
||||
this._hiddenTabGroupByRef = new Map(Object.entries(groupProjection.hiddenTabGroupByRef));
|
||||
} else {
|
||||
parts.push(...rowHtml.values());
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
this._hiddenTabGroupByRef = new Map();
|
||||
}
|
||||
this._lastTabGroupStructureKey = this._tabGroupStructureKey(groupProjection);
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -5750,7 +5807,11 @@ class CodemanApp {
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted()) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
// Grouped rail: `order` only sorts WITHIN a group's own flex column, so
|
||||
// the walk sorts by group first (-1 for every row of the flat strip).
|
||||
const groups = [...container.querySelectorAll('.tab-layout-group')];
|
||||
const groupOf = (el) => groups.indexOf(el.closest('.tab-layout-group'));
|
||||
tabs.sort((a, b) => groupOf(a) - groupOf(b) || orderOf(a) - orderOf(b));
|
||||
}
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
@@ -5873,6 +5934,131 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Owner tab layout: grouped vertical rail (read-only)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// The server owns named tab groups (GET /api/tab-layout, tab-layout*.ts) and
|
||||
// already projects them onto the global session order, so sessionOrder, Alt+N,
|
||||
// Ctrl+Tab and every other order consumer are untouched here. This layer only
|
||||
// decides how the VERTICAL rail draws rows: in sections, with per-device
|
||||
// collapse. With no groups (or any read failure) the rail is the flat list it
|
||||
// has always been.
|
||||
|
||||
_ensureTabLayoutCoordinator() {
|
||||
if (this._tabLayoutCoordinator) return this._tabLayoutCoordinator;
|
||||
if (!window.CodemanTabLayout) return null;
|
||||
this._tabLayoutCoordinator = window.CodemanTabLayout.createLoadCoordinator({
|
||||
fetchLayout: async () => {
|
||||
const data = await this._apiJson('/api/tab-layout');
|
||||
if (!data?.layout) throw new Error('Tab layout unavailable');
|
||||
return data.layout;
|
||||
},
|
||||
applyLayout: (layout) => this._applyTabLayout(layout),
|
||||
applyFallback: () => this._applyTabLayout(null),
|
||||
scheduleRetry: (retry) => setTimeout(retry, 5000),
|
||||
cancelRetry: (timer) => clearTimeout(timer),
|
||||
});
|
||||
return this._tabLayoutCoordinator;
|
||||
}
|
||||
|
||||
_loadTabLayout() {
|
||||
return this._ensureTabLayoutCoordinator()?.load() ?? Promise.resolve(false);
|
||||
}
|
||||
|
||||
/** SSE tab:layoutChanged carries `{ owner, version }`; the layout itself is re-read. */
|
||||
_onTabLayoutChanged(data) {
|
||||
const me = window.__codemanUser;
|
||||
// Another user's layout changed: nothing of ours moved. (The GET is
|
||||
// owner-scoped server-side, so this is a saved request, not a guard.)
|
||||
if (me?.multiUser && typeof data?.owner === 'string' && data.owner !== me.username) return;
|
||||
if (Number.isSafeInteger(data?.version) && this.tabLayout && data.version <= this.tabLayout.version) return;
|
||||
this._loadTabLayout();
|
||||
}
|
||||
|
||||
/** Adopt a layout read (or null after a failed read, which renders flat). */
|
||||
_applyTabLayout(layout) {
|
||||
let next = null;
|
||||
if (layout) {
|
||||
try {
|
||||
next = window.CodemanTabLayout.normalizeLayout(layout);
|
||||
} catch {
|
||||
next = null;
|
||||
}
|
||||
}
|
||||
// An overtaken response is already dropped by the coordinator; this guards a
|
||||
// reordering between the coordinator and an SSE-triggered reload.
|
||||
if (next && this.tabLayout && next.version < this.tabLayout.version) return;
|
||||
this.tabLayout = next;
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const collapsed = storage && next
|
||||
? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id))
|
||||
: { ids: [], ok: !next };
|
||||
if (!collapsed.ok) this._tabCollapseStorageFailed = true;
|
||||
this.collapsedTabGroupIds = new Set(collapsed.ids);
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
/** localStorage, or null once it has failed (collapse then stays all-expanded). */
|
||||
_getTabCollapseStorage() {
|
||||
if (this._tabCollapseStorageFailed) return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
this._tabCollapseStorageFailed = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The grouped projection for the CURRENT render, or null when the rail should
|
||||
* render flat: horizontal strip (incl. phones and the sidebar, which force it),
|
||||
* no layout yet, a failed read, or a layout without groups.
|
||||
*/
|
||||
_projectTabGroups() {
|
||||
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return null;
|
||||
return window.CodemanTabLayout.project(this.tabLayout, {
|
||||
// sessionOrder, not the sessions Map: rows are built in that order, so a
|
||||
// session the layout has not placed yet lands where the flat strip has it.
|
||||
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
|
||||
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
|
||||
collapsedGroupIds: [...this.collapsedTabGroupIds],
|
||||
activeSessionId: this.activeSessionId,
|
||||
activeWebviewId: this.activeWebviewId,
|
||||
});
|
||||
}
|
||||
|
||||
_tabGroupStructureKey(projection) {
|
||||
return window.CodemanTabLayout?.structureKey(this.tabLayout, projection, [...this.collapsedTabGroupIds]) ?? null;
|
||||
}
|
||||
|
||||
/** True when the DOM's grouping no longer matches what a render would draw. */
|
||||
_isTabGroupStructureStale(projection = this._projectTabGroups()) {
|
||||
return this._tabGroupStructureKey(projection) !== this._lastTabGroupStructureKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse/expand one group (header click). Per-device: stored in localStorage,
|
||||
* never sent to the server, so collapsing on a laptop leaves the desktop alone.
|
||||
* A storage failure leaves every group expanded rather than half-remembered.
|
||||
*/
|
||||
toggleTabGroupCollapsed(groupId, forceCollapsed) {
|
||||
if (!this.tabLayout?.groups?.some((group) => group.id === groupId)) return false;
|
||||
const next = new Set(this.collapsedTabGroupIds);
|
||||
const shouldCollapse = forceCollapsed === undefined ? !next.has(groupId) : forceCollapsed === true;
|
||||
if (shouldCollapse) next.add(groupId);
|
||||
else next.delete(groupId);
|
||||
const storage = this._getTabCollapseStorage();
|
||||
const saved = storage
|
||||
? window.CodemanTabLayout.saveCollapsedGroupIds(storage, [...next])
|
||||
: { ids: [], ok: false };
|
||||
if (!saved.ok) this._tabCollapseStorageFailed = true;
|
||||
this.collapsedTabGroupIds = new Set(saved.ids);
|
||||
// The full render also redraws connectors anchored to rows that just moved.
|
||||
this._fullRenderSessionTabs();
|
||||
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -5884,7 +6070,10 @@ class CodemanApp {
|
||||
// affordance instead of lying about it — `tabRailSort: 'manual'` is the way
|
||||
// back to drag-reordering, and Alt+N / Ctrl+Shift+{ } still walk the strip
|
||||
// order this list is no longer showing.
|
||||
if (this.isTabRailSorted()) {
|
||||
// The grouped rail is read-only for now: a flat-order drag cannot express
|
||||
// "move into that group", and the server would re-rank it within its old
|
||||
// group anyway. Grouped editing comes with its own drag model.
|
||||
if (this.isTabRailSorted() || container.classList.contains('session-tabs--grouped')) {
|
||||
tabs.forEach((tab) => tab.setAttribute('draggable', 'false'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
'Open away digest': '打开离开期间摘要',
|
||||
'Session Manager': '会话管理器',
|
||||
'Session actions': '会话操作',
|
||||
Ungrouped: '未分组',
|
||||
'Open session manager': '打开会话管理器',
|
||||
Attachments: '附件',
|
||||
'Open attachment history': '打开附件历史',
|
||||
|
||||
@@ -3739,6 +3739,8 @@
|
||||
<script defer src="terminal-keycode229-recovery.js"></script>
|
||||
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
|
||||
<script defer src="sanitize-html.js"></script>
|
||||
<!-- Owner tab layout projection (grouped vertical rail); pure, read by app.js. -->
|
||||
<script defer src="tab-layout-browser.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.js"></script>
|
||||
<script defer src="terminal-ui.js"></script>
|
||||
|
||||
@@ -153,7 +153,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const edges = this._collectLineageEdges();
|
||||
if (edges.length === 0) return;
|
||||
this._lineageEdgeCount = edges.length;
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
// PHASE 1 — reads.
|
||||
@@ -162,19 +161,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stripRect = strip.getBoundingClientRect();
|
||||
const orientation =
|
||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
||||
// to anchor to, so its end of the arc moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
||||
// header would be an arc from a row to itself: skipped.
|
||||
const resolveEndpoint = (id) => {
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
|
||||
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
|
||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||
};
|
||||
const resolvedEdges = [];
|
||||
for (const edge of edges) {
|
||||
for (const id of [edge.parentId, edge.childId]) {
|
||||
const key = 'tab:' + id;
|
||||
if (rects.has(key)) continue;
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
rects.set(key, tab ? tab.getBoundingClientRect() : null);
|
||||
const parentEndpoint = resolveEndpoint(edge.parentId);
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (parentEndpoint.key === childEndpoint.key) continue;
|
||||
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
|
||||
for (const endpoint of [parentEndpoint, childEndpoint]) {
|
||||
if (rects.has(endpoint.key)) continue;
|
||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
||||
}
|
||||
}
|
||||
this._lineageEdgeCount = resolvedEdges.length;
|
||||
|
||||
// PHASE 2 — writes, from the cache only.
|
||||
for (const edge of edges) {
|
||||
const parentRect = rects.get('tab:' + edge.parentId);
|
||||
const childRect = rects.get('tab:' + edge.childId);
|
||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||
const parentRect = rects.get(parentEndpoint.key);
|
||||
const childRect = rects.get(childEndpoint.key);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({
|
||||
@@ -191,7 +206,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// The working class marches the dashes, so an active worker is visible along
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working);
|
||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
||||
@@ -211,7 +227,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||
// works, so the two have to be changed together.
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
|
||||
@@ -634,6 +634,78 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- Grouped rail (owner tab layout) ---------------------------------- */
|
||||
/* Only rendered when the owner has at least one group (tab-layout-browser.js).
|
||||
Each group's rows sit in their OWN flex column, so the activity sort's inline
|
||||
`order` (and web tabs' `order: 9999`) rank rows within a group, never across. */
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group,
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-refs {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group + .tab-layout-group {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 4px 6px 2px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab-layout-group-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-layout-group-count {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tab-layout-group-chevron::before {
|
||||
content: '\25BE';
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tab-layout-group-toggle[aria-expanded='false'] .tab-layout-group-chevron::before {
|
||||
content: '\25B8';
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .header-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -10510,6 +10582,21 @@ kbd {
|
||||
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
}
|
||||
|
||||
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
|
||||
and dotted, so it reads as "somewhere in there" rather than a real row. */
|
||||
.connection-line.lineage-line--proxied {
|
||||
stroke-dasharray: 2 7;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line--proxied.lineage-line--working {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.lineage-line-dot.lineage-line-dot--proxied {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
/* The child end marches while that worker is actually working, so the line
|
||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview Read-only browser projection of the owner tab layout.
|
||||
*
|
||||
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
|
||||
* is the server model). Browser assets cannot import that TypeScript, so this
|
||||
* module is a small, dependency-free mirror that owns three things:
|
||||
*
|
||||
* 1. Projection: which live sessions and open web tabs land in which group,
|
||||
* and which rows a collapsed group hides.
|
||||
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
|
||||
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
|
||||
* byte-identical to the flat rail's row.
|
||||
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
|
||||
* read degrades to the flat rail with a bounded retry.
|
||||
*
|
||||
* The server stays the only authority for layout content. Collapse is a
|
||||
* per-device view preference and lives in localStorage only.
|
||||
*
|
||||
* Grouped rendering is opt-in by construction: a layout with no groups (every
|
||||
* owner until they create one) projects to `null`, and the caller keeps the flat
|
||||
* rail exactly as it was.
|
||||
*
|
||||
* @dependency none
|
||||
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
|
||||
*/
|
||||
|
||||
(function initCodemanTabLayout(global) {
|
||||
'use strict';
|
||||
|
||||
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
|
||||
|
||||
const refKey = (ref) => `${ref.kind}:${ref.id}`;
|
||||
const validRef = (ref) =>
|
||||
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
|
||||
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
|
||||
const stableIds = (value) => [...new Set(asIds(value))];
|
||||
const copyRefs = (value) =>
|
||||
Array.isArray(value) ? value.filter(validRef).map((r) => ({ kind: r.kind, id: r.id })) : [];
|
||||
|
||||
/**
|
||||
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
|
||||
* server adding model fields cannot leak half-understood state into the view.
|
||||
*/
|
||||
function normalizeLayout(value) {
|
||||
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
|
||||
const groups = Array.isArray(value.groups) ? value.groups : [];
|
||||
return {
|
||||
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
|
||||
groups: groups
|
||||
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
name: typeof group.name === 'string' ? group.name : '',
|
||||
refs: copyRefs(group.refs),
|
||||
})),
|
||||
ungrouped: copyRefs(value.ungrouped),
|
||||
};
|
||||
}
|
||||
|
||||
function hasGroups(layout) {
|
||||
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
|
||||
}
|
||||
|
||||
function loadCollapsedGroupIds(storage, validGroupIds) {
|
||||
try {
|
||||
const raw = storage.getItem(COLLAPSED_STORAGE_KEY);
|
||||
const parsed = raw === null ? [] : JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) throw new Error('Invalid collapsed tab groups');
|
||||
const loaded = stableIds(parsed);
|
||||
if (validGroupIds === undefined) return { ids: loaded, ok: true };
|
||||
// Garbage-collect ids of groups that no longer exist, so a deleted group's
|
||||
// id cannot silently collapse a future group that reuses it.
|
||||
const valid = new Set(stableIds(validGroupIds));
|
||||
const kept = loaded.filter((id) => valid.has(id));
|
||||
if (kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
|
||||
return { ids: kept, ok: true };
|
||||
} catch (_error) {
|
||||
return { ids: [], ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsedGroupIds(storage, groupIds) {
|
||||
const ids = stableIds(groupIds);
|
||||
try {
|
||||
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
|
||||
return { ids, ok: true };
|
||||
} catch (_error) {
|
||||
return { ids: [], ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a layout onto what is live in this browser.
|
||||
*
|
||||
* Every live session and open web tab appears exactly once: stored refs keep
|
||||
* their group and stored order; anything the layout has not caught up with yet
|
||||
* (a session created a moment ago, a web tab opened on this device only) is
|
||||
* appended to the ungrouped section in the caller's order. Saved web tabs that
|
||||
* are not open here are skipped, as are refs to sessions that are gone.
|
||||
*
|
||||
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
|
||||
* tab, else the active session), so selecting a hidden session by keyboard,
|
||||
* palette or Alt+N never leaves the user with no visible selection.
|
||||
*
|
||||
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef }} null when the
|
||||
* layout has no groups: the caller renders the flat rail unchanged.
|
||||
*/
|
||||
function project(layoutInput, options = {}) {
|
||||
if (!layoutInput) return null;
|
||||
const layout = normalizeLayout(layoutInput);
|
||||
if (!hasGroups(layout)) return null;
|
||||
const liveSessionIds = stableIds(options.liveSessionIds);
|
||||
const openWebviewIds = stableIds(options.openWebviewIds);
|
||||
const live = new Set(liveSessionIds);
|
||||
const open = new Set(openWebviewIds);
|
||||
const collapsed = new Set(asIds(options.collapsedGroupIds));
|
||||
const highlighted = options.activeWebviewId
|
||||
? `webview:${options.activeWebviewId}`
|
||||
: options.activeSessionId
|
||||
? `session:${options.activeSessionId}`
|
||||
: '';
|
||||
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
|
||||
const placed = new Set();
|
||||
const visibleRefs = [];
|
||||
const hiddenTabGroupByRef = {};
|
||||
const sections = [];
|
||||
|
||||
const place = (refs, sectionId, isCollapsed) => {
|
||||
const shown = [];
|
||||
let count = 0;
|
||||
for (const ref of refs) {
|
||||
const key = refKey(ref);
|
||||
if (placed.has(key) || !renderable(ref)) continue;
|
||||
placed.add(key);
|
||||
count++;
|
||||
if (isCollapsed && key !== highlighted) {
|
||||
hiddenTabGroupByRef[key] = sectionId;
|
||||
continue;
|
||||
}
|
||||
const copy = { kind: ref.kind, id: ref.id };
|
||||
shown.push(copy);
|
||||
visibleRefs.push(copy);
|
||||
}
|
||||
return { shown, count };
|
||||
};
|
||||
|
||||
for (const group of layout.groups) {
|
||||
const isCollapsed = collapsed.has(group.id);
|
||||
const { shown, count } = place(group.refs, group.id, isCollapsed);
|
||||
sections.push({ id: group.id, name: group.name, refs: shown, count, collapsed: isCollapsed });
|
||||
}
|
||||
const omissions = [
|
||||
...liveSessionIds.map((id) => ({ kind: 'session', id })),
|
||||
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
|
||||
];
|
||||
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
|
||||
if (ungrouped.count > 0) {
|
||||
sections.push({ id: null, name: '', refs: ungrouped.shown, count: ungrouped.count, collapsed: false });
|
||||
}
|
||||
return { sections, visibleRefs, hiddenTabGroupByRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
|
||||
* where), as opposed to a row's own status/name/badges. The incremental render
|
||||
* path only patches rows in place, so a change here forces a full rebuild.
|
||||
*/
|
||||
function structureKey(layout, projection, collapsedGroupIds) {
|
||||
if (!projection) return null;
|
||||
return JSON.stringify({
|
||||
version: layout && Number.isSafeInteger(layout.version) ? layout.version : null,
|
||||
collapsed: stableIds(collapsedGroupIds).sort(),
|
||||
sections: projection.sections.map((section) => [section.id, section.count, section.refs.map(refKey)]),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
|
||||
* `escapeHtml` is the caller's escaper. Group names are user content, so they
|
||||
* are escaped and marked `data-i18n-skip`.
|
||||
*
|
||||
* The group header is a real <button> carrying `aria-expanded`; its accessible
|
||||
* name is the group name plus count, so no per-state label string is needed.
|
||||
*/
|
||||
function renderProjection(projection, renderRef, escapeHtml) {
|
||||
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
|
||||
return sections
|
||||
.map((section, index) => {
|
||||
const rows = section.refs.map((ref) => renderRef(ref)).join('');
|
||||
if (section.id === null) {
|
||||
return (
|
||||
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
|
||||
`<div class="tab-layout-group-header tab-layout-ungrouped-header"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
|
||||
);
|
||||
}
|
||||
const id = escapeHtml(section.id);
|
||||
const refsId = `tab-layout-group-refs-${index}`;
|
||||
return (
|
||||
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
|
||||
`<button type="button" class="tab-layout-group-header tab-layout-group-toggle" data-tab-group-header="${id}" aria-expanded="${section.collapsed ? 'false' : 'true'}" aria-controls="${refsId}" onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
|
||||
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
|
||||
`<span class="tab-layout-group-name" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
||||
`<span class="tab-layout-group-count">${section.count}</span></button>` +
|
||||
`<div class="tab-layout-group-refs" id="${refsId}" role="presentation">${rows}</div></section>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest-wins layout loading. A response that was overtaken by a later load is
|
||||
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
|
||||
* retry, replacing any retry already pending.
|
||||
*/
|
||||
function createLoadCoordinator(options) {
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let retryHandle = null;
|
||||
const clearRetry = () => {
|
||||
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
|
||||
retryHandle = null;
|
||||
};
|
||||
const load = async () => {
|
||||
if (disposed) return false;
|
||||
const requestGeneration = ++generation;
|
||||
clearRetry();
|
||||
try {
|
||||
const layout = await options.fetchLayout();
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
options.applyLayout(layout);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (disposed || requestGeneration !== generation) return false;
|
||||
options.applyFallback();
|
||||
retryHandle = options.scheduleRetry(() => load());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
return {
|
||||
load,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
generation++;
|
||||
clearRetry();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.CodemanTabLayout = {
|
||||
normalizeLayout,
|
||||
hasGroups,
|
||||
project,
|
||||
structureKey,
|
||||
renderProjection,
|
||||
createLoadCoordinator,
|
||||
loadCollapsedGroupIds,
|
||||
saveCollapsedGroupIds,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -301,13 +301,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
if (!this.webviews.get(id)) continue;
|
||||
parts.push(this.renderWebviewTab(id, idx));
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
/**
|
||||
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
|
||||
* The grouped vertical rail places single web tabs into their group with this,
|
||||
* so a web tab's markup is the same in every layout.
|
||||
*/
|
||||
renderWebviewTab(id, idx) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return '';
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
|
||||
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
@@ -319,10 +332,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span></span>
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
</div>`;
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
|
||||
Reference in New Issue
Block a user