mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(web): optional collapsible left session sidebar
The header tab strip stops working past roughly a dozen sessions: it wraps into two or three rows, eats vertical space and still cannot be scanned. This adds a vertical session list in a left <aside> as an ALTERNATIVE layout — a filter box, a live count, and a 44px collapsed rail that keeps the ambient signal (status dot, task badge) visible. The strip is not removed. Settings -> Display -> Tab Bar -> Session List Layout switches between them and the default stays 'header', so existing users see no change until they opt in. Structure: one #sessionTabs element, two mount points. applySessionListLayout() re-parents the SAME node between #sessionTabsHost and #sessionSidebarList, which is why there is no second renderer and no duplicated wiring — app.$() caches getElementById results and never invalidates them, so a moved node keeps every existing consumer (settings-ui, webview-tabs, the generated gesture bundle, the mobile tests) working untouched. Notable integration points: - Below 1024px the sidebar is an off-canvas drawer overlaying the terminal; closed it gets inert + aria-hidden so it cannot be tabbed into, and touch swipes over it no longer switch sessions. - Subagent and ultracode windows anchor to the right edge of a sidebar row instead of its bottom, connector curves follow. - Alt+B toggles; the chord is gated out of the PTY so xterm cannot also write ESC b into a live session. - Collapse state lives in its own localStorage key (the settings blob is rebuilt from DOM controls on every save) and falls back to in-memory intent where storage throws. Verified: frontend syntax + public asset checks, tsc, eslint, 26 new jsdom tests, and a headless-Chromium harness (scripts/verify-session-sidebar.mts) that renders a synthetic 25-session fleet in both layouts at 1600/1000/393px and asserts mount point, widths, inert/aria state and row count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+368
-14
@@ -421,6 +421,16 @@ const DEFAULT_SHORTCUTS = [
|
||||
],
|
||||
action: 'openCommandPalette',
|
||||
},
|
||||
{
|
||||
id: 'toggle-session-sidebar',
|
||||
group: 'Session',
|
||||
label: 'Toggle Session Sidebar',
|
||||
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
|
||||
// readline backward-char). The Alt block below claims only Digit1-9 and
|
||||
// the brackets, and the registry claims Alt for KeyK and Slash only.
|
||||
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
||||
action: 'toggleSessionSidebar',
|
||||
},
|
||||
{
|
||||
id: 'previous-next-session',
|
||||
group: 'Session',
|
||||
@@ -814,7 +824,9 @@ class CodemanApp {
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
|
||||
// and then applies the sidebar variant on top — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyMonitorVisibility();
|
||||
// Must run before the first session:created can arrive: markSessionTabEntering()
|
||||
// ignores ids until this sets up its state, which is what keeps the tabs
|
||||
@@ -878,7 +890,7 @@ class CodemanApp {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applySessionListLayout();
|
||||
this.applyMonitorVisibility();
|
||||
// ultracodeFloatingWindows syncs from the server (non-display key), but on a
|
||||
// FRESH device the getLightState run snapshot can seed workflowRuns BEFORE this
|
||||
@@ -999,6 +1011,7 @@ class CodemanApp {
|
||||
toggleVoiceInput: () => VoiceInput.toggle(),
|
||||
moveActiveTabLeft: () => this.moveActiveTabLeft(),
|
||||
moveActiveTabRight: () => this.moveActiveTabRight(),
|
||||
toggleSessionSidebar: () => this.toggleSessionSidebar(),
|
||||
};
|
||||
|
||||
// Use capture to handle before terminal
|
||||
@@ -1020,6 +1033,14 @@ class CodemanApp {
|
||||
this.closeSessionManager();
|
||||
this.closeCommandPalette?.();
|
||||
this.closeShortcutOverlay?.();
|
||||
// Overlay layouts only: below 1024px the sidebar is a modal off-canvas
|
||||
// drawer over the terminal, so Escape must close it. The docked desktop
|
||||
// sidebar is chrome, not a dialog — collapsing it would be a surprise.
|
||||
if (this._isSessionSidebarOverlay() &&
|
||||
this.isSessionSidebarActive() && !this.isSessionSidebarCollapsed()) {
|
||||
this.toggleSessionSidebar();
|
||||
document.getElementById('sidebarToggleBtn')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||
@@ -3261,6 +3282,256 @@ class CodemanApp {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session List Layout (header strip ⟷ collapsible left sidebar)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 'header' | 'sidebar'. Solo (detached single-session) windows are ALWAYS
|
||||
* 'header': they show exactly one session, so a session list is noise — and
|
||||
* #sessionTabs must never be parked inside the display:none <aside>, where
|
||||
* updateTabOverflowMode() would measure 0/0 and the inline rename input would
|
||||
* get zero geometry.
|
||||
*/
|
||||
getSessionListLayout() {
|
||||
if (this.soloSessionId) return 'header';
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const layout = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
return layout === 'sidebar' ? 'sidebar' : 'header';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the APPLIED layout off <html>, not the settings blob: this is called
|
||||
* per dragover event and per tab in render loops, and getSessionListLayout()
|
||||
* re-parses localStorage on every call. The attribute is written by the
|
||||
* pre-paint script in index.html and thereafter only by applySessionListLayout(),
|
||||
* so it is authoritative from the very first frame.
|
||||
*/
|
||||
isSessionSidebarActive() {
|
||||
return document.documentElement.dataset.sessionList === 'sidebar';
|
||||
}
|
||||
|
||||
/**
|
||||
* True where the sidebar is a MODAL off-canvas drawer over the terminal
|
||||
* instead of a docked column.
|
||||
*
|
||||
* That behaviour is defined purely in mobile.css, which index.html loads with
|
||||
* media="(max-width: 1023px)" — so this must test the SAME breakpoint.
|
||||
* MobileDetection.getDeviceType() is NOT usable here: it calls anything
|
||||
* >= 768px 'desktop', which would leave 768-1023px (iPad portrait, a narrowed
|
||||
* desktop window) with overlay CSS but docked-sidebar logic — drawer opens
|
||||
* itself on load, tapping a session doesn't dismiss it, Escape does nothing.
|
||||
* Mirrored in the pre-paint script in index.html.
|
||||
*/
|
||||
_isSessionSidebarOverlay() {
|
||||
return window.innerWidth < 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse state is per-device and lives in its OWN localStorage key, not in
|
||||
* the app-settings blob: saveAppSettings() rebuilds that blob from the DOM
|
||||
* controls, so any key without a control is silently wiped on every Save.
|
||||
* Precedent: codeman:skin, codeman-session-order, codeman-active-session.
|
||||
*/
|
||||
isSessionSidebarCollapsed() {
|
||||
// In-memory intent wins over storage: where localStorage throws (Safari
|
||||
// private mode, disabled storage, quota) the write in toggleSessionSidebar()
|
||||
// is a no-op, and re-reading here would return the OLD value — the sidebar
|
||||
// would refuse to collapse at all. Persistence degrades, the control does not.
|
||||
if (this._sidebarCollapsedOverride !== undefined) return this._sidebarCollapsedOverride;
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem('codeman-sidebar-collapsed');
|
||||
} catch {}
|
||||
// Never chosen yet: the docked desktop sidebar starts open, the overlay
|
||||
// drawer starts CLOSED — "expanded" there would mean a drawer covering the
|
||||
// terminal on every cold load.
|
||||
if (raw === null) return this._isSessionSidebarOverlay();
|
||||
return raw === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this keydown is the sidebar-toggle chord AND toggling would
|
||||
* actually do something. Used by terminal-ui.js's custom key handler to keep
|
||||
* the chord out of the PTY: the document CAPTURE handler has already toggled
|
||||
* the sidebar by the time xterm sees the event, but its preventDefault() does
|
||||
* NOT stop xterm — without this gate Alt+B would ALSO write ESC b into the
|
||||
* live session, which readline/Ink read as backward-word and which walks the
|
||||
* cursor back through whatever the user was typing (same trap as COD-153).
|
||||
*
|
||||
* Deliberately registry-aware and gated on the sidebar being active, so a
|
||||
* rebound/disabled shortcut — and the default header layout, where the toggle
|
||||
* is a no-op — leave Meta-b reaching the terminal exactly as before.
|
||||
*/
|
||||
shouldToggleSessionSidebarFromShortcut(e) {
|
||||
if (!e) return false;
|
||||
// Every dispatchable binding requires Ctrl/Cmd/Alt, so plain typing exits
|
||||
// before any registry work — this runs on the xterm keydown hot path.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
|
||||
if (!this.isSessionSidebarActive()) return false;
|
||||
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const shortcut = this.getShortcutRegistry().find((s) => s.id === 'toggle-session-sidebar');
|
||||
if (!shortcut || shortcut.disabled) return false;
|
||||
return this.matchesShortcutEvent(e, shortcut);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the ONE #sessionTabs element between its two hosts and set the layout
|
||||
* attributes that all the sidebar CSS keys off.
|
||||
*
|
||||
* Never clones or recreates the node: this.$('sessionTabs') caches elements by
|
||||
* id and never invalidates, and settings-ui.js / webview-tabs.js resolve the
|
||||
* same id independently. A rebuilt container would leave every consumer
|
||||
* writing into a detached orphan — silently, with no error.
|
||||
*/
|
||||
applySessionListLayout() {
|
||||
const mode = this.getSessionListLayout();
|
||||
const collapsed = this.isSessionSidebarCollapsed();
|
||||
const prevMode = document.documentElement.dataset.sessionList;
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const headerHost = document.getElementById('sessionTabsHost');
|
||||
const sidebarList = document.getElementById('sessionSidebarList');
|
||||
if (!tabsEl || !headerHost || !sidebarList) return;
|
||||
|
||||
const host = mode === 'sidebar' ? sidebarList : headerHost;
|
||||
if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
|
||||
|
||||
document.documentElement.dataset.sessionList = mode;
|
||||
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
|
||||
tabsEl.setAttribute('aria-orientation', mode === 'sidebar' ? 'vertical' : 'horizontal');
|
||||
|
||||
const btn = document.getElementById('sidebarToggleBtn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('btn-sidebar-toggle--hidden', mode !== 'sidebar');
|
||||
const label = collapsed ? 'Expand session sidebar' : 'Collapse session sidebar';
|
||||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('title', label);
|
||||
}
|
||||
|
||||
// Handheld (mobile.css): the sidebar is an off-canvas overlay, and
|
||||
// "collapsed" means the drawer is closed.
|
||||
const aside = document.getElementById('sessionSidebar');
|
||||
if (aside) {
|
||||
aside.classList.toggle('open', mode === 'sidebar' && !collapsed);
|
||||
// A closed overlay drawer is only moved off screen by translateX(-100%);
|
||||
// it keeps display:flex, so without this its filter box and ~4 tab stops
|
||||
// per session stay in the Tab order and in the accessibility tree.
|
||||
// NOT applied to the docked desktop rail — its rows are still clickable.
|
||||
const hiddenDrawer = mode === 'sidebar' && collapsed && this._isSessionSidebarOverlay();
|
||||
aside.toggleAttribute('inert', hiddenDrawer);
|
||||
if (hiddenDrawer) aside.setAttribute('aria-hidden', 'true');
|
||||
else aside.removeAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
// The filter box only exists inside the sidebar; leaving a stale filter
|
||||
// applied when the layout goes back to the header strip would hide sessions
|
||||
// from the tab bar with no reachable control to clear it.
|
||||
if (mode !== 'sidebar') {
|
||||
this._sidebarFilter = '';
|
||||
const filterInput = document.getElementById('sessionSidebarFilter');
|
||||
if (filterInput) filterInput.value = '';
|
||||
}
|
||||
|
||||
// applyTabWrapSettings() (settings-ui.js) is the ONE owner of
|
||||
// tabs-two-rows / tabs-show-folder / _tallTabsEnabled and is itself
|
||||
// sidebar-aware — it reads the data-session-list attribute set just above,
|
||||
// so it must run AFTER it. It re-renders by itself when the folder row
|
||||
// appears or disappears.
|
||||
const prevTall = this._tallTabsEnabled;
|
||||
this.applyTabWrapSettings();
|
||||
// A layout flip alone still needs one render: the rows are rebuilt into the
|
||||
// new host with the drag/keyboard handlers re-bound. Skipped when
|
||||
// applyTabWrapSettings() already rendered for the folder-row change.
|
||||
if (prevMode !== mode && prevTall === this._tallTabsEnabled) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
|
||||
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
|
||||
// for a frame before the next measure.
|
||||
if (mode === 'sidebar') tabsEl.classList.remove('tabs-auto-wrap');
|
||||
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
|
||||
// here too — not only at the render tails.
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
this.updateSidebarCount();
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
|
||||
toggleSessionSidebar() {
|
||||
if (!this.isSessionSidebarActive()) return;
|
||||
const collapsed = !this.isSessionSidebarCollapsed();
|
||||
this._sidebarCollapsedOverride = collapsed;
|
||||
try {
|
||||
localStorage.setItem('codeman-sidebar-collapsed', collapsed ? '1' : '0');
|
||||
} catch {}
|
||||
// Collapsing hides the filter row. If focus is sitting in there it would be
|
||||
// reset to <body>, dropping the user back to the top of the tab order — so
|
||||
// hand it to the toggle, which is the control they just used.
|
||||
if (collapsed && this.$('sessionSidebar')?.contains(document.activeElement)) {
|
||||
document.getElementById('sidebarToggleBtn')?.focus();
|
||||
}
|
||||
this.applySessionListLayout();
|
||||
// Opening the MODAL drawer moves focus into it, as a dialog should. The
|
||||
// docked desktop sidebar is not modal: stealing focus there would pull the
|
||||
// caret out of the terminal mid-prompt, and .session-tab handles only
|
||||
// arrows/Home/End/Enter/Space, so everything typed after would be swallowed.
|
||||
if (!collapsed && this._isSessionSidebarOverlay()) {
|
||||
this.$('sessionTabs')?.querySelector('.session-tab.active')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay layouts only: below 1024px the sidebar is a modal drawer on top of
|
||||
* the terminal (mobile.css), so picking a session from it must get it out of
|
||||
* the way again. The docked desktop sidebar stays exactly where the user put
|
||||
* it. No-op unless the drawer is actually open.
|
||||
*/
|
||||
closeSessionSidebarOnHandheld() {
|
||||
if (!this._isSessionSidebarOverlay()) return;
|
||||
if (!this.isSessionSidebarActive() || this.isSessionSidebarCollapsed()) return;
|
||||
this.toggleSessionSidebar();
|
||||
}
|
||||
|
||||
updateSidebarCount() {
|
||||
const el = document.getElementById('sessionSidebarCount');
|
||||
if (el) el.textContent = String(this.sessions?.size ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar filter box. Pure DOM class toggling — no re-render, no state on the
|
||||
* sessions themselves. Matches the rendered aria-label (session name) and the
|
||||
* title (working directory).
|
||||
*
|
||||
* Re-applied at the tail of both render paths: _fullRenderSessionTabs() rebuilds
|
||||
* innerHTML wholesale, so without that the filtered-out rows flicker back in on
|
||||
* every SSE tick.
|
||||
*
|
||||
* The filter only takes effect while the box that produced it is on screen —
|
||||
* i.e. the expanded sidebar. In the header strip, the collapsed rail or a
|
||||
* closed drawer the classes come off, otherwise sessions would stay hidden
|
||||
* with no visible cause and no reachable control to clear them. The remembered
|
||||
* needle is restored when the box comes back.
|
||||
*/
|
||||
applySidebarFilter(query) {
|
||||
this._sidebarFilter = (query ?? '').trim().toLowerCase();
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
const reachable =
|
||||
this.isSessionSidebarActive() && document.documentElement.dataset.sidebar !== 'collapsed';
|
||||
const needle = reachable ? this._sidebarFilter : '';
|
||||
for (const tab of container.querySelectorAll('.session-tab')) {
|
||||
if (!needle) {
|
||||
tab.classList.remove('tab-filtered-out');
|
||||
continue;
|
||||
}
|
||||
const haystack = `${tab.getAttribute('aria-label') || ''} ${tab.getAttribute('title') || ''}`.toLowerCase();
|
||||
tab.classList.toggle('tab-filtered-out', !haystack.includes(needle));
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Tabs
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -3279,12 +3550,54 @@ class CodemanApp {
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.id === sessionId) {
|
||||
tab.classList.add('active');
|
||||
// With 25+ sessions the active row is routinely below the fold of the
|
||||
// sidebar's own scroller. 'nearest' never scrolls when it is visible.
|
||||
if (this.isSessionSidebarActive()) tab.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a floating window (subagent / ultracode) attaches to its parent tab.
|
||||
* Header strip: below the tab, connector runs vertically. Sidebar: to the
|
||||
* RIGHT of the tab, connector runs horizontally — otherwise the window spawns
|
||||
* on top of the sidebar and its bezier loops backwards underneath it.
|
||||
*/
|
||||
_tabAnchor(rect) {
|
||||
if (this.isSessionSidebarActive()) {
|
||||
return {
|
||||
x: rect.right,
|
||||
y: rect.top + rect.height / 2,
|
||||
spawnLeft: rect.right + 14,
|
||||
spawnTop: rect.top,
|
||||
vertical: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom,
|
||||
spawnLeft: rect.left,
|
||||
spawnTop: rect.bottom,
|
||||
vertical: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bezier from a _tabAnchor() to a window rect, curving along the right axis. */
|
||||
_tabConnectorPath(anchor, winRect) {
|
||||
if (anchor.vertical) {
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (anchor.y + y2) / 2;
|
||||
return `M ${anchor.x} ${anchor.y} C ${anchor.x} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
}
|
||||
const x2 = winRect.left;
|
||||
const y2 = winRect.top + winRect.height / 2;
|
||||
const midX = (anchor.x + x2) / 2;
|
||||
return `M ${anchor.x} ${anchor.y} C ${midX} ${anchor.y}, ${midX} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
_setTerminalLoadState(sessionId, selectGen, phase) {
|
||||
this.terminalLoadStates.set(sessionId, { generation: selectGen, phase });
|
||||
this._updateTerminalLoadTab(sessionId);
|
||||
@@ -3502,6 +3815,9 @@ class CodemanApp {
|
||||
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
|
||||
// already funnels through here. No-ops unless that surface is showing.
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
this.updateSidebarCount();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
@@ -3511,6 +3827,13 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
// The sidebar list is a single vertical column with its own scroller —
|
||||
// there is no row to overflow, and measuring it would fight the CSS.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
container.classList.remove('tabs-auto-wrap');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceType = MobileDetection.getDeviceType();
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -3540,16 +3863,27 @@ class CodemanApp {
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Sidebar rows are always tall (name + folder) and never wrap. Re-assert it
|
||||
// here so a render triggered straight from applyTabWrapSettings() — which
|
||||
// only knows the header strip — cannot leave the sidebar folderless.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
this._tallTabsEnabled = true;
|
||||
container.classList.add('tabs-show-folder');
|
||||
container.classList.remove('tabs-two-rows', 'tabs-auto-wrap');
|
||||
}
|
||||
|
||||
// Clean up any orphaned dropdowns before re-rendering
|
||||
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
|
||||
this.cancelHideSubagentDropdown();
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance
|
||||
// Iterate in sessionOrder to respect user's custom tab arrangement
|
||||
// On mobile: put active session first (only one tab visible anyway)
|
||||
// On mobile: put active session first (only one tab visible anyway) — but NOT
|
||||
// in the sidebar, which is a scrollable vertical drawer where hoisting would
|
||||
// scramble the user's drag-reordered sessionOrder on every switch.
|
||||
const parts = [];
|
||||
let tabOrder = this.sessionOrder;
|
||||
if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) {
|
||||
if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId && !this.isSessionSidebarActive()) {
|
||||
// Reorder to put active tab first
|
||||
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
|
||||
}
|
||||
@@ -3631,6 +3965,11 @@ class CodemanApp {
|
||||
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
|
||||
// than restarting, since this rebuild just destroyed the animating elements.
|
||||
this._applyTabEntrances?.();
|
||||
|
||||
// innerHTML was rebuilt wholesale, so the sidebar filter classes are gone —
|
||||
// re-apply them or filtered-out sessions flicker back on every SSE tick.
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
this.updateSidebarCount();
|
||||
}
|
||||
|
||||
// Set up arrow key navigation for session tabs (accessibility)
|
||||
@@ -3641,9 +3980,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this._tabKeydownHandler = (e) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
|
||||
// Up/Down are aliases of Left/Right, not replacements: the strip stays
|
||||
// arrow-key navigable exactly as before, the vertical sidebar just gains
|
||||
// the axis a user reaches for there.
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' '].includes(e.key)) return;
|
||||
|
||||
const tabs = [...container.querySelectorAll('.session-tab')];
|
||||
// Rows hidden by the sidebar filter must not be steppable.
|
||||
const tabs = [...container.querySelectorAll('.session-tab:not(.tab-filtered-out)')];
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
// Enter or Space activates the tab
|
||||
@@ -3659,9 +4002,11 @@ class CodemanApp {
|
||||
let newIndex;
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
newIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
|
||||
break;
|
||||
case 'Home':
|
||||
@@ -3793,14 +4138,19 @@ class CodemanApp {
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Determine drop position based on mouse position
|
||||
// Determine drop position based on mouse position. Read the layout here,
|
||||
// inside the handler — these listeners survive a layout flip between
|
||||
// renders, so capturing the axis at bind time would go stale.
|
||||
// drag-over-left/-right keep their names and now read as before/after;
|
||||
// the sidebar CSS just draws them as top/bottom edges.
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const isLeftHalf = e.clientX < midpoint;
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
// Update visual indicator
|
||||
tab.classList.toggle('drag-over-left', isLeftHalf);
|
||||
tab.classList.toggle('drag-over-right', !isLeftHalf);
|
||||
tab.classList.toggle('drag-over-left', insertBefore);
|
||||
tab.classList.toggle('drag-over-right', !insertBefore);
|
||||
});
|
||||
|
||||
tab.addEventListener('dragleave', () => {
|
||||
@@ -3816,10 +4166,11 @@ class CodemanApp {
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
// Determine insertion position
|
||||
// Determine insertion position (same axis rule as the dragover handler)
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const insertBefore = e.clientX < midpoint;
|
||||
const insertBefore = this.isSessionSidebarActive()
|
||||
? e.clientY < rect.top + rect.height / 2
|
||||
: e.clientX < rect.left + rect.width / 2;
|
||||
|
||||
// Reorder sessionOrder array
|
||||
const fromIndex = this.sessionOrder.indexOf(draggedId);
|
||||
@@ -4170,6 +4521,9 @@ class CodemanApp {
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||
this._updateActiveTabImmediate(sessionId);
|
||||
// Handheld: the session drawer overlays the terminal, so slide it away now
|
||||
// that a session has been picked. No-op on desktop and in header layout.
|
||||
this.closeSessionSidebarOnHandheld();
|
||||
this.renderSessionTabs();
|
||||
this.updateAttachmentHistoryBadge?.();
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
|
||||
Reference in New Issue
Block a user