feat(tabs): pin tabs to a Focus section on the vertical rail

A session or web-tab ref in the owner tab layout may now carry
`focus: true`. "Add to Focus" in a row's menu (right-click on a web tab)
sets it; the vertical rail then draws a shortcut to that tab in a pinned,
collapsible "Focus" section at its top, while the row itself stays where
it is. A click on a shortcut selects its tab, a right-click or the menu
key offers "Remove from Focus".

Model: `TabRef.focus?: true`, parsed strictly (only `true` or absent) and
carried through normalization, every pure edit and the legacy-order
projection; `setTabFocus()` is the pure helper. TabLayoutService needed
no change and no route was added: the flag rides the existing
`PUT /api/tab-layout`. Browser: `copyRef`/`normalizeLayout` keep `focus`
the way they keep `placement` (the browser writes whole layouts, so a
dropped field would be erased on every save), `setFocus` is a named
operation through `createEditCoordinator` and replays on a 409, and
`focusRefs()` (also on `project()`) lists what the rail can draw.

The section is its own element in #tabRail above #sessionTabs, so it
works on the flat rail (no named groups) as well as the grouped one, and
shortcuts are never rows: no Alt+N badge, no tree item, no lineage
anchor, no drag target. It is a list with one tab stop and
Up/Down/Home/End between shortcuts. Collapse is per-device localStorage
under its own key. A shortcut whose session closed or whose web tab is
not open drops out; the server already prunes deleted refs. zh-CN
strings added.
This commit is contained in:
Aamer Akhter committed 2026-10-10 13:43:20 -04:00
1 parent aa3730aace
commit cde90a465b
15 files changed
+1819 -16

No files matched your search

+236 -2
View File
@@ -6158,6 +6158,8 @@ class CodemanApp {
}
}
this._syncTabTreeSelection(container);
// The Focus shortcut of the selected tab is marked aria-current.
this._renderTabFocus();
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
@@ -6649,6 +6651,10 @@ class CodemanApp {
this._refreshMobileOverviewIfVisible?.();
// Same deal for the desktop home screen's tab column.
this._refreshHomeSessionsIfVisible?.();
// Focus shortcuts carry the status dot and alert of the tab they stand for.
// A no-op unless what the section would draw changed (and after a full
// rebuild above it already has).
this._renderTabFocus();
// The full-render path already redraws the connection SVG; this incremental
// one does not, and a badge appearing widens a tab and shifts every tab after
// it, sliding the lineage lines off their anchors. Only pay for it when there
@@ -7001,6 +7007,10 @@ class CodemanApp {
// Set up keyboard navigation for tabs
this.setupTabKeyboardNavigation(container);
// The Focus section sits above this list in the rail, so it is drawn before
// the connection lines measure the rows.
this._renderTabFocus();
// Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines();
@@ -7535,6 +7545,9 @@ class CodemanApp {
// rail (always so on the flat rail, which is every owner without groups).
// Rebuild only when what the rail would draw actually changed.
if (this._isTabGroupStructureStale()) this._fullRenderSessionTabs();
// A Focus flag set on another device is not a structural change of the
// list, so it would not rebuild it: draw the section on its own.
else if (this._renderTabFocus()) this.updateConnectionLines?.();
// Edits left unsaved by the previous page (see _persistPendingTabLayoutEdits).
if (next && !this._tabLayoutRestoreChecked) {
this._tabLayoutRestoreChecked = true;
@@ -7608,6 +7621,226 @@ class CodemanApp {
return this.collapsedTabGroupIds.has(groupId) === shouldCollapse;
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: the Focus section (pinned shortcuts)
// ═══════════════════════════════════════════════════════════════
//
// A ref flagged `focus: true` in the layout gets a SHORTCUT in a small
// "Focus" section pinned to the top of the vertical rail; the row itself stays
// in its group. The section is its own element in #tabRail, OUTSIDE
// #sessionTabs, on purpose: it shows above the flat rail as well as the
// grouped one, and nothing that walks #sessionTabs (Alt+N badges, the tree's
// roving stop and posinset, lineage lines, drag, the row filters, the rail
// sort) ever sees a shortcut. Its own keyboard model is a list with one tab
// stop and Up/Down/Home/End between shortcuts; each shortcut is a button, so
// Enter/Space select its tab, and the context-menu key opens "Remove from
// Focus". Collapse is per-device localStorage, like a group's.
/** True when `ref` carries `focus: true` in the held layout. */
_tabRefFocused(ref) {
const location = this._tabRefLocation(ref);
return location?.refs[location.index]?.focus === true;
}
/** Pin or unpin a row. One named operation through the edit coordinator. */
setTabRefFocus(ref, focused) {
if (!this._tabRefLocation(ref)) return false;
return this.editTabLayout({ type: 'setFocus', ref: { kind: ref.kind, id: ref.id }, focused: focused === true }, `${ref.kind}:${ref.id}`);
}
/** The row menu's Focus entry ([] where the rail cannot edit the layout). */
_tabRefFocusActions(ref) {
if (!this._tabLayoutEditable() || !this._tabRefLocation(ref)) return [];
const focused = this._tabRefFocused(ref);
return [{ label: focused ? 'Remove from Focus' : 'Add to Focus', run: () => this.setTabRefFocus(ref, !focused) }];
}
_tabFocusCollapsedNow() {
if (this._tabFocusCollapsed === undefined) {
const storage = this._getTabCollapseStorage();
const loaded = storage ? window.CodemanTabLayout.loadFocusCollapsed(storage) : { collapsed: false, ok: false };
if (!loaded.ok) this._tabCollapseStorageFailed = true;
this._tabFocusCollapsed = loaded.collapsed;
}
return this._tabFocusCollapsed;
}
/** Collapse/expand the Focus section. Per-device; a store failure leaves it expanded. */
toggleTabFocusCollapsed(forceCollapsed) {
const shouldCollapse = forceCollapsed === undefined ? !this._tabFocusCollapsedNow() : forceCollapsed === true;
const storage = this._getTabCollapseStorage();
const saved = storage ? window.CodemanTabLayout.saveFocusCollapsed(storage, shouldCollapse) : { collapsed: false, ok: false };
if (!saved.ok) this._tabCollapseStorageFailed = true;
this._tabFocusCollapsed = saved.collapsed;
if (this._renderTabFocus()) this.updateConnectionLines?.();
document.getElementById('tabFocusToggle')?.focus();
return this._tabFocusCollapsed === shouldCollapse;
}
/** The focused refs this rail draws now ([] off the vertical rail or without a layout). */
_tabFocusRefs() {
if (!this.tabLayout || this._tabOrientation() !== 'vertical' || !window.CodemanTabLayout) return [];
return window.CodemanTabLayout.focusRefs(this.tabLayout, {
liveSessionIds: this.sessionOrder.filter((id) => this.sessions.has(id)),
openWebviewIds: (this.webviewOrder || []).filter((id) => this.webviews?.has(id)),
});
}
/** The Focus section element, created once inside #tabRail just above the list. */
_tabFocusHost() {
let host = document.getElementById('tabFocus');
if (host) return host;
const rail = document.getElementById('tabRail');
if (!rail) return null;
host = document.createElement('section');
host.id = 'tabFocus';
host.className = 'tab-focus';
host.hidden = true;
host.setAttribute('aria-labelledby', 'tabFocusTitle');
// Delegated, so the listeners survive every repaint of the section.
host.addEventListener('keydown', (event) => this._handleTabFocusKeydown(event));
host.addEventListener('click', (event) => {
if (event.target?.closest?.('#tabFocusToggle')) {
this.toggleTabFocusCollapsed();
return;
}
const item = event.target?.closest?.('.tab-focus-item');
if (item) this.openTabFocusShortcut(event, item.dataset.focusKey);
});
host.addEventListener('contextmenu', (event) => {
const item = event.target?.closest?.('.tab-focus-item');
if (item) this.openTabFocusMenu(event, item.dataset.focusKey, item);
});
const list = this.$('sessionTabs');
if (list && list.parentElement === rail) rail.insertBefore(host, list);
else rail.appendChild(host);
return host;
}
/**
* Draw the Focus section from the held layout. Cheap enough for every render
* pass: the markup is compared with what was drawn last, and the DOM is only
* touched when it changed. Returns true when it did (the rail's list moved,
* so connector lines anchored to its rows need a redraw).
*/
_renderTabFocus() {
const refs = this._tabFocusRefs();
const host = refs.length ? this._tabFocusHost() : document.getElementById('tabFocus');
if (!host) return false;
if (!refs.length) {
if (host.hidden && !host.firstChild) return false;
host.hidden = true;
host.replaceChildren();
this._lastTabFocusHtml = '';
return true;
}
// Keep the rail's list just below the section: applyTabOrientation()
// re-appends #sessionTabs to the rail when the orientation flips.
const list = this.$('sessionTabs');
const rail = host.parentElement;
if (list && rail && list.parentElement === rail && host.nextElementSibling !== list) rail.insertBefore(host, list);
const collapsed = this._tabFocusCollapsedNow();
const activeKey = this.activeWebviewId
? `webview:${this.activeWebviewId}`
: this.activeSessionId
? `session:${this.activeSessionId}`
: '';
const focusedEl = host.contains(document.activeElement) ? document.activeElement : null;
const focusedKey = focusedEl?.dataset?.focusKey || (focusedEl?.id === 'tabFocusToggle' ? 'toggle' : null);
const keys = refs.map((ref) => `${ref.kind}:${ref.id}`);
// One tab stop in the list: the shortcut that had it, else the selected
// tab's, else the first.
const stopKey = [focusedKey, this._tabFocusStopKey, activeKey].find((key) => key && keys.includes(key)) || keys[0];
const items = refs
.map((ref) => {
const key = `${ref.kind}:${ref.id}`;
let name;
let statusHtml;
let alertClass = '';
if (ref.kind === 'session') {
const session = this.sessions.get(ref.id);
name = this.getSessionName(session);
statusHtml = `<span class="tab-status ${escapeHtml(session.status || 'idle')}" aria-hidden="true"></span>`;
const alert = this.tabAlerts?.get(ref.id);
alertClass = alert === 'action' ? ' tab-alert-action' : alert === 'idle' ? ' tab-alert-idle' : '';
} else {
const webview = this.webviews.get(ref.id);
name = webview?.name || ref.id;
statusHtml = `<span class="tab-web-icon" aria-hidden="true">${webview?.icon ? escapeHtml(webview.icon) : this._webviewGlobeIcon?.() || ''}</span>`;
}
// The name is user text: the whole button is kept away from the
// translator (a session named "3 sessions" must not come out in Chinese).
return (
`<li class="tab-focus-entry"><button type="button" class="tab-focus-item${alertClass}" data-focus-key="${escapeHtml(key)}" data-i18n-skip ` +
`tabindex="${key === stopKey ? '0' : '-1'}" aria-current="${key === activeKey ? 'true' : 'false'}" aria-label="${escapeHtml(name)}" title="${escapeHtml(name)}">` +
`${statusHtml}<span class="tab-focus-name">${escapeHtml(name)}</span></button></li>`
);
})
.join('');
const html =
`<button type="button" id="tabFocusToggle" class="tab-focus-toggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="tabFocusList">` +
`<span class="tab-layout-group-chevron" aria-hidden="true"></span><span class="tab-focus-title" id="tabFocusTitle">Focus</span>` +
`<span class="tab-focus-count" aria-hidden="true">${refs.length}</span></button>` +
`<ul class="tab-focus-list" id="tabFocusList" aria-labelledby="tabFocusTitle"${collapsed ? ' hidden' : ''}>${items}</ul>`;
if (!host.hidden && this._lastTabFocusHtml === html) return false;
host.classList.toggle('tab-focus--collapsed', collapsed);
host.innerHTML = html;
host.hidden = false;
this._lastTabFocusHtml = html;
if (focusedKey === 'toggle') document.getElementById('tabFocusToggle')?.focus();
else if (focusedKey) host.querySelector(`[data-focus-key="${CSS.escape(focusedKey)}"]`)?.focus();
return true;
}
_tabFocusRefFromKey(key) {
const at = typeof key === 'string' ? key.indexOf(':') : -1;
if (at < 0) return null;
const kind = key.slice(0, at);
return kind === 'session' || kind === 'webview' ? { kind, id: key.slice(at + 1) } : null;
}
/** A shortcut was clicked: select its tab, exactly as the row would. */
openTabFocusShortcut(event, key) {
const ref = this._tabFocusRefFromKey(key);
if (!ref) return undefined;
this._tabFocusStopKey = key;
if (ref.kind === 'webview') return this.handleWebviewTabClick?.(event, ref.id);
return this.handleSessionTabClick(event, ref.id);
}
/** Right-click, Shift+F10 or the menu key on a shortcut (`trigger` anchors the menu). */
openTabFocusMenu(event, key, trigger) {
const ref = this._tabFocusRefFromKey(key);
if (!ref) return false;
this._tabFocusStopKey = key;
// A delegated listener's currentTarget is the section; anchor on the shortcut.
const anchored = trigger
? { preventDefault: () => event?.preventDefault?.(), stopPropagation: () => event?.stopPropagation?.(), currentTarget: trigger, type: event?.type, clientX: event?.clientX }
: event;
return this._openTabLayoutMenu(anchored, `focus:${key}`, 'Focus actions', [
{ label: 'Remove from Focus', run: () => this.setTabRefFocus(ref, false) },
]);
}
/** Up/Down/Home/End move between shortcuts; the list keeps one tab stop. */
_handleTabFocusKeydown(event) {
const current = event.target?.closest?.('.tab-focus-item');
if (!current || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const items = [...event.currentTarget.querySelectorAll('.tab-focus-item')];
const index = items.indexOf(current);
const next =
event.key === 'Home'
? items[0]
: event.key === 'End'
? items[items.length - 1]
: items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
if (!next) return;
event.preventDefault();
for (const item of items) item.tabIndex = item === next ? 0 : -1;
this._tabFocusStopKey = next.dataset.focusKey;
next.focus();
}
// ═══════════════════════════════════════════════════════════════
// Owner tab layout: editing groups from the vertical rail
// ═══════════════════════════════════════════════════════════════
@@ -7822,7 +8055,7 @@ class CodemanApp {
// live tree item by identity.
const rail = this.$('sessionTabs');
const item =
(trigger?.isConnected && trigger.closest('[role="treeitem"]')) ||
(trigger?.isConnected && (trigger.closest('[role="treeitem"]') || trigger.closest('.tab-focus-item'))) ||
[...(rail?.querySelectorAll('[role="treeitem"]') || [])].find((el) => this._tabTreeIdentity(el) === identity);
item?.focus();
}
@@ -7848,7 +8081,8 @@ class CodemanApp {
/** Keyboard actions for a web-tab row in the vertical rail: its settings plus group moves. */
openTabWebviewMenu(event, webviewId) {
const moves = this._tabRefMoveActions({ kind: 'webview', id: webviewId });
const ref = { kind: 'webview', id: webviewId };
const moves = [...this._tabRefFocusActions(ref), ...this._tabRefMoveActions(ref)];
if (!moves.length) {
this.showWebviewModal?.(webviewId);
return false;
+5
View File
@@ -93,6 +93,11 @@
'Move down': '下移',
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
// The Focus section pinned at the top of the vertical rail (shortcuts to tabs).
Focus: '焦点',
'Add to Focus': '添加到焦点',
'Remove from Focus': '从焦点移除',
'Focus actions': '焦点操作',
'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
+135
View File
@@ -815,6 +815,141 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tabs--grouped .tab-layo
user-select: none;
}
/* --- Focus section (pinned shortcuts, app.js _renderTabFocus) ---------- */
/* Its own element above #sessionTabs in the rail, so it shows above the flat
rail and the grouped one alike and never takes part in the list's order,
tree or Alt+N numbering. Capped so a long Focus never starves the list. */
.tab-focus {
display: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus:not([hidden]) {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 2px;
max-height: 40%;
min-height: 0;
padding: 0.35rem 0.35rem 0.3rem calc(0.35rem + 24px);
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 4px 6px 2px;
border: 0;
border-radius: 4px;
color: var(--text);
background: transparent;
font: inherit;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: left;
cursor: pointer;
user-select: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-toggle:hover {
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail :is(.tab-focus-toggle, .tab-focus-item):focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-focus-title {
flex: 1 1 auto;
min-width: 0;
}
.tab-focus-count {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.tab-focus--collapsed .tab-layout-group-chevron::before {
content: '\25B8';
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list {
display: flex;
flex-direction: column;
gap: 2px;
min-height: 0;
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-list[hidden] {
display: none;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item {
position: relative;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 3px 8px;
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
border-radius: 4px;
color: var(--text-dim, var(--text-muted));
background: transparent;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item:hover {
color: var(--text);
background: var(--bg-hover);
}
html[data-tab-orientation='vertical'] .tab-rail .tab-focus-item[aria-current='true'] {
color: var(--text);
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.tab-focus-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The tab's own status dot and alert colour, so a shortcut says what its tab
says (the row keeps its full alert ring; a shortcut only recolours the dot). */
.tab-focus-item .tab-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: var(--green);
}
.tab-focus-item .tab-status.error { background: var(--red); }
.tab-focus-item .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
.tab-focus-item.tab-alert-action .tab-status { background: var(--red); box-shadow: 0 0 6px rgba(239, 68, 68, 0.7); }
.tab-focus-item.tab-alert-idle .tab-status { background: var(--yellow); box-shadow: 0 0 6px rgba(234, 179, 8, 0.6); }
.tab-focus-item .tab-web-icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
color: var(--text-dim, var(--text-muted));
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-drag-active {
cursor: grabbing;
}
+79 -8
View File
@@ -13,7 +13,7 @@
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
* row) applied optimistically and saved through ONE serialized
* row, pin a row to Focus) applied optimistically and saved through ONE serialized
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
* version conflict.
*
@@ -32,17 +32,26 @@
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
// The Focus section's collapse, per device. Its own key, not an entry in the
// group list above: that list is garbage-collected against the layout's group
// ids, and Focus is not a group.
const FOCUS_COLLAPSED_STORAGE_KEY = 'codeman:tab-focus-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))];
// `placement: 'manual'` must survive the round trip: the browser writes whole
// layouts back, and dropping it would re-attach a hand-placed child session to
// its parent's subtree on the next save.
const copyRef = (r) =>
r.placement === 'manual' ? { kind: r.kind, id: r.id, placement: 'manual' } : { kind: r.kind, id: r.id };
// `placement: 'manual'` and `focus: true` must survive the round trip: the
// browser writes whole layouts back, so a field dropped here is erased on the
// next save, by whoever saves. Losing placement re-attaches a hand-placed child
// session to its parent's subtree; losing focus unpins every Focus shortcut.
const copyRef = (r) => {
const copy = { kind: r.kind, id: r.id };
if (r.placement === 'manual') copy.placement = 'manual';
if (r.focus === true) copy.focus = true;
return copy;
};
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
@@ -108,6 +117,52 @@
}
}
/**
* The Focus section's per-device collapse. `ok: false` means the store threw;
* any value other than 'true' reads as expanded.
*/
function loadFocusCollapsed(storage) {
try {
return { collapsed: storage.getItem(FOCUS_COLLAPSED_STORAGE_KEY) === 'true', ok: true };
} catch (_error) {
return { collapsed: false, ok: false };
}
}
function saveFocusCollapsed(storage, collapsed) {
try {
storage.setItem(FOCUS_COLLAPSED_STORAGE_KEY, collapsed === true ? 'true' : 'false');
return { collapsed: collapsed === true, ok: true };
} catch (_error) {
return { collapsed: false, ok: false };
}
}
/**
* The refs pinned to the Focus section that this browser can draw: focused,
* a live session or an open web tab, each once, in stored order (groups in
* order, then Ungrouped). Works with or without named groups, so Focus also
* shows above the flat rail. A ref whose session closed or whose web tab is
* not open here simply drops out; the server prunes deleted refs on its own.
*/
function focusRefs(layoutInput, options = {}) {
if (!layoutInput) return [];
const layout = normalizeLayout(layoutInput);
const live = new Set(asIds(options.liveSessionIds));
const open = new Set(asIds(options.openWebviewIds));
const seen = new Set();
const result = [];
for (const ref of [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]) {
if (ref.focus !== true) continue;
if (!(ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id))) continue;
const key = refKey(ref);
if (seen.has(key)) continue;
seen.add(key);
result.push({ kind: ref.kind, id: ref.id });
}
return result;
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
@@ -131,11 +186,13 @@
* 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, sectionByRef }}
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
* `focusRefs` is focusRefs() for the same options: shortcuts, never rows,
* so they are in none of the other fields.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
@@ -200,7 +257,7 @@
collapsed: false,
});
}
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef };
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) };
}
const ALERT_RANK = { action: 2, idle: 1 };
@@ -526,6 +583,17 @@
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
return layout;
}
case 'setFocus': {
// Pin or unpin one row. It never moves: Focus draws a shortcut, and the
// row stays in its group. Idempotent, so a replay after a 409 is safe.
if (!validRef(op.ref)) editError('invalid row');
if (typeof op.focused !== 'boolean') editError('invalid Focus state');
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(op.ref));
if (!location) editError('unknown row');
if (op.focused) location.ref.focus = true;
else delete location.ref.focus;
return layout;
}
case 'moveRef': {
if (!validRef(op.ref)) editError('invalid row');
const targetKey = refKey(op.ref);
@@ -745,6 +813,9 @@
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
focusRefs,
loadFocusCollapsed,
saveFocusCollapsed,
MAX_GROUPS,
};
})(typeof window !== 'undefined' ? window : globalThis);
+2 -1
View File
@@ -327,7 +327,8 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// Group placement (vertical rail with a tab layout only; [] elsewhere).
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
this.detachedSessions?.has(sessionId)
+12
View File
@@ -325,6 +325,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
oncontextmenu="app.handleWebviewTabContextMenu(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
@@ -347,6 +348,16 @@ Object.assign(CodemanApp.prototype, {
return this.openWebview(id);
},
/**
* Right-click (or the menu key) on a web tab in the vertical rail opens its
* actions (Focus, group moves, settings), the flat rail included, where it is
* the only way in. Everywhere else the browser keeps its own menu.
*/
handleWebviewTabContextMenu(event, id) {
if (!this._tabLayoutEditable?.()) return false;
return this.openTabWebviewMenu?.(event, id);
},
/** Mark exactly one tab active across BOTH tab kinds. */
_updateActiveWebviewTab() {
const container = this.$('sessionTabs');
@@ -359,6 +370,7 @@ Object.assign(CodemanApp.prototype, {
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
this._syncTabTreeSelection?.(container);
this._renderTabFocus?.();
},
// ── Opening / closing ─────────────────────────────────────────────────────