mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +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) {
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
'Skip to terminal': '跳转到终端',
|
||||
'Go to main page': '返回主页',
|
||||
'Session tabs': '会话标签页',
|
||||
/* 'Sessions' (the sidebar heading) is already mapped further down. */
|
||||
'Collapse session sidebar': '收起会话侧边栏',
|
||||
'Expand session sidebar': '展开会话侧边栏',
|
||||
'Filter sessions': '筛选会话',
|
||||
'Admin Panel': '管理面板',
|
||||
'Open admin panel': '打开管理面板',
|
||||
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
|
||||
@@ -226,6 +230,11 @@
|
||||
'Cron Button': '定时任务按钮',
|
||||
'Redraw Terminal Button': '重绘终端按钮',
|
||||
'Tab Bar': '标签栏',
|
||||
'Session List Layout': '会话列表布局',
|
||||
'Header tab strip': '顶栏标签条',
|
||||
'Left sidebar': '左侧边栏',
|
||||
'Show the session list as a horizontal strip in the header, or as a collapsible sidebar on the left':
|
||||
'将会话列表显示为顶栏中的横向标签条,或左侧的可折叠侧边栏',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
layer loads below; setting lang/dir here prevents an English accessibility
|
||||
tree from flashing while the deferred scripts start. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
|
||||
<!-- Apply the saved session-list layout (header strip vs. left sidebar) and the
|
||||
sidebar collapse state before first paint, so the loading skeleton and the
|
||||
first frame already match. Same per-device settings key as the language
|
||||
script above. Solo windows (/session/:id) never get a sidebar — mirrors
|
||||
_detectSoloSessionId() in app.js. With no stored collapse choice the
|
||||
docked desktop sidebar starts open and the off-canvas overlay drawer
|
||||
starts closed — the overlay test is `innerWidth < 1024`, matching
|
||||
mobile.css's media attribute below and _isSessionSidebarOverlay() in
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');document.documentElement.dataset.sessionList=(L==='sidebar'&&!solo)?'sidebar':'header';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebar='expanded';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -57,8 +69,22 @@
|
||||
.skeleton-brand{color:var(--accent,#38b6f0);font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
.skeleton-tab{width:80px;height:24px;background:var(--control-bg,rgba(255,255,255,0.04));border-radius:6px}
|
||||
.skeleton-body{flex:1;display:flex;min-height:0}
|
||||
.skeleton-sidebar{display:none;width:44px;flex:0 0 44px;background:var(--glass-bg,rgba(31,38,48,0.85));border-right:1px solid var(--glass-border,rgba(255,255,255,0.08))}
|
||||
.skeleton-terminal{flex:1;background:var(--term-bg,#161b23)}
|
||||
.skeleton-toolbar{height:42px;background:var(--glass-bg,rgba(31,38,48,0.85));border-top:1px solid var(--glass-border,rgba(255,255,255,0.08))}
|
||||
/* Sidebar layout: the strip skeleton would flash a grey pill where no strip
|
||||
will be, so swap it for a rail matching --sidebar-width-collapsed. */
|
||||
html[data-session-list="sidebar"] .skeleton-tabs{display:none}
|
||||
/* Only >=1024px docks the sidebar and reserves layout width; below that it is
|
||||
an off-canvas overlay, so a rail in the skeleton would be a strip that
|
||||
vanishes. The pre-paint script has already resolved the collapse state, so
|
||||
match the real width and spare the terminal a 216px sideways jump once
|
||||
styles.css lands. */
|
||||
@media (min-width: 1024px) {
|
||||
html[data-session-list="sidebar"] .skeleton-sidebar{display:block}
|
||||
html[data-session-list="sidebar"][data-sidebar="expanded"] .skeleton-sidebar{width:260px;flex:0 0 260px}
|
||||
}
|
||||
.app-loaded .loading-skeleton{display:none}
|
||||
</style>
|
||||
</head>
|
||||
@@ -69,7 +95,10 @@
|
||||
<span class="skeleton-brand">Codeman</span>
|
||||
<div class="skeleton-tabs"><div class="skeleton-tab"></div></div>
|
||||
</div>
|
||||
<div class="skeleton-terminal"></div>
|
||||
<div class="skeleton-body">
|
||||
<div class="skeleton-sidebar"></div>
|
||||
<div class="skeleton-terminal"></div>
|
||||
</div>
|
||||
<div class="skeleton-toolbar"></div>
|
||||
</div>
|
||||
<!-- Skip link for keyboard users -->
|
||||
@@ -83,10 +112,27 @@
|
||||
<span class="logo" onclick="app.goHome()" title="Go to main page"
|
||||
><span class="logo-text">Codeman</span><span class="logo-compact" aria-hidden="true">C</span></span
|
||||
>
|
||||
<!-- Collapse/expand the session sidebar. Lives in .header-brand, NOT in
|
||||
#headerRight: test/mobile-header-buttons-policy.test.ts only enumerates
|
||||
buttons inside .header-right, and on a phone this button is the only
|
||||
way to open the off-canvas session drawer, so it must never be hidden
|
||||
by the phone header policy. Shown only in sidebar layout — visibility
|
||||
via marker class, never inline style. -->
|
||||
<button class="btn-icon-header btn-sidebar-toggle btn-sidebar-toggle--hidden"
|
||||
id="sidebarToggleBtn" onclick="app.toggleSessionSidebar()"
|
||||
title="Collapse session sidebar" aria-label="Collapse session sidebar"
|
||||
aria-expanded="true" aria-controls="sessionSidebar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Session Tabs -->
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
<!-- Session Tabs. In sidebar layout THIS VERY #sessionTabs element is
|
||||
re-parented into #sessionSidebarList by applySessionListLayout() and
|
||||
this host is hidden — it is never cloned or rebuilt, because
|
||||
app.$('sessionTabs') caches it by object identity and never invalidates. -->
|
||||
<div class="session-tabs-host" id="sessionTabsHost">
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs" aria-orientation="horizontal">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
@@ -293,6 +339,26 @@
|
||||
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in
|
||||
markup: applySessionListLayout() moves #sessionTabs in here, so the
|
||||
vertical list is the exact same DOM node as the header strip and every
|
||||
renderer, drag handler and webview-tabs.js consumer keeps working.
|
||||
Must stay a SIBLING of .terminal-wrap — .main.webview-active hides
|
||||
.terminal-wrap, and the sidebar has to survive that. -->
|
||||
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
|
||||
<div class="session-sidebar-head">
|
||||
<span class="session-sidebar-title">Sessions</span>
|
||||
<span class="session-sidebar-count" id="sessionSidebarCount" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="session-sidebar-filter">
|
||||
<input type="search" id="sessionSidebarFilter" class="session-sidebar-filter-input"
|
||||
placeholder="Filter sessions" aria-label="Filter sessions"
|
||||
autocomplete="off" spellcheck="false"
|
||||
oninput="app.applySidebarFilter(this.value)">
|
||||
</div>
|
||||
<div class="session-sidebar-list" id="sessionSidebarList"></div>
|
||||
</aside>
|
||||
|
||||
<div class="terminal-wrap">
|
||||
<div class="terminal-container" id="terminalContainer"></div>
|
||||
<textarea id="cjkInput" rows="1" placeholder="CJK input (Enter = send, Esc = clear)"
|
||||
@@ -633,6 +699,7 @@
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>B</kbd></div><div>Toggle Session Sidebar</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="shortcut-section">
|
||||
@@ -640,8 +707,8 @@
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
|
||||
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd>ArrowLeft</kbd> / <kbd>ArrowUp</kbd></div><div>Focus Previous Tab</div>
|
||||
<div><kbd>ArrowRight</kbd> / <kbd>ArrowDown</kbd></div><div>Focus Next Tab</div>
|
||||
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
|
||||
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
|
||||
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
|
||||
@@ -1473,6 +1540,13 @@
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
<div class="settings-item" title="Show the session list as a horizontal strip in the header, or as a collapsible sidebar on the left">
|
||||
<span class="settings-item-label">Session List Layout</span>
|
||||
<select id="appSettingsSessionListLayout" class="form-select">
|
||||
<option value="header">Header tab strip</option>
|
||||
<option value="sidebar">Left sidebar</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
|
||||
<span class="settings-item-label">Tall Tabs (Name + Folder)</span>
|
||||
<label class="switch switch-sm">
|
||||
|
||||
@@ -168,6 +168,11 @@ const MobileDetection = {
|
||||
resizeTimeout = setTimeout(() => {
|
||||
this.updateBodyClass();
|
||||
this.updateAppHeight();
|
||||
// Whether the session sidebar is a docked column or a modal overlay is
|
||||
// decided at 1024px, so crossing that width has to re-sync the drawer
|
||||
// state — otherwise the `inert`/aria-hidden set on a closed overlay
|
||||
// drawer survives into the docked rail and makes it unclickable.
|
||||
if (typeof app !== 'undefined') app.applySessionListLayout?.();
|
||||
// Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
|
||||
// other trigger is a tab content render. No-op on mobile/tablet (method bails).
|
||||
if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
|
||||
@@ -581,6 +586,7 @@ const SwipeHandler = {
|
||||
_touchStartHandler: null,
|
||||
_touchEndHandler: null,
|
||||
_element: null,
|
||||
_ignoreGesture: false,
|
||||
|
||||
/** Initialize swipe handling */
|
||||
init() {
|
||||
@@ -609,6 +615,12 @@ const SwipeHandler = {
|
||||
},
|
||||
|
||||
onTouchStart(e) {
|
||||
// The session sidebar is an overlay child of .main, so its touches bubble in
|
||||
// here. Swiping across the open session drawer — the natural "dismiss it"
|
||||
// gesture — would otherwise fire nextSession() and drop the user into a
|
||||
// session they never tapped.
|
||||
this._ignoreGesture = !!e.target?.closest?.('.session-sidebar');
|
||||
if (this._ignoreGesture) return;
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
this.startX = e.touches[0].clientX;
|
||||
this.startY = e.touches[0].clientY;
|
||||
@@ -616,6 +628,10 @@ const SwipeHandler = {
|
||||
},
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (this._ignoreGesture) {
|
||||
this._ignoreGesture = false;
|
||||
return;
|
||||
}
|
||||
if (!e.changedTouches || e.changedTouches.length !== 1) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
|
||||
@@ -468,6 +468,20 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Exception to the 26px shrink above: in sidebar layout this button is the
|
||||
ONLY way to open the session list — the strip it replaced is gone. A 26px
|
||||
target is below --touch-target-min (44px), which the 430-768px block
|
||||
already enforces for every other header button. */
|
||||
html[data-session-list='sidebar'] #sidebarToggleBtn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
html[data-session-list='sidebar'] #sidebarToggleBtn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear, lifecycle log, away digest, session manager, and
|
||||
file viewer on mobile - settings moved to toolbar; the others are secondary /
|
||||
desktop-oriented controls that don't belong on the cramped phone header (the
|
||||
@@ -2872,3 +2886,117 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
padding: 4px 7px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
SESSION SIDEBAR — off-canvas drawer (tablet + phone)
|
||||
============================================================================
|
||||
This whole file is served with media="(max-width: 1023px)", so these
|
||||
top-level rules cover the entire handheld range — deliberately NOT wrapped in
|
||||
a nested @media, because the two compact `.session-tabs` blocks above live in
|
||||
`max-width: 768px` and `max-width: 430px` and would leave 769-1023px
|
||||
unhandled.
|
||||
|
||||
Placement at the END of the file is load-bearing: the compact strip blocks at
|
||||
lines ~117 and ~584 use the deliberate `.session-tabs, .session-tabs.tabs-two-rows`
|
||||
(0,2,0) doubling documented there. The sidebar selectors below are (0,2,1)
|
||||
and up AND come later, so they win on both counts. Move this block and the
|
||||
list collapses to a 36px sliver that looks like an empty list.
|
||||
|
||||
Why an overlay instead of the desktop rail: 44px is 11% of a 393px viewport.
|
||||
Below 1024px the sidebar never occupies layout width — it slides over the
|
||||
terminal, following the .attachment-history-drawer recipe in styles.css.
|
||||
`collapsed` therefore means "drawer closed", and applySessionListLayout()
|
||||
mirrors that into the `.open` class. */
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: min(280px, 80vw);
|
||||
flex: 0 0 auto;
|
||||
transform: translateX(-100%);
|
||||
/* visibility, not just transform: an off-screen drawer keeps display:flex, so
|
||||
without this its filter box and ~4 tab stops per session stay in the Tab
|
||||
order and in the a11y tree. applySessionListLayout() also sets `inert`; this
|
||||
is the CSS half, and the transition keeps it visible for the slide-out. */
|
||||
visibility: hidden;
|
||||
transition: transform var(--sidebar-transition), visibility var(--sidebar-transition);
|
||||
box-shadow: 10px 0 28px rgba(0, 0, 0, 0.36);
|
||||
z-index: 12;
|
||||
padding-left: var(--safe-area-left);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar.open {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Collapsed == closed here, so the desktop icon-rail styling must not apply:
|
||||
the drawer keeps its full width and its head/filter/labels while it is off
|
||||
screen, otherwise opening it would animate in a 44px stub. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar {
|
||||
flex-basis: auto;
|
||||
width: min(280px, 80vw);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-head,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-filter {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .session-tab {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Undo the rail's content trimming: these rows are full-width drawer rows, just
|
||||
currently off screen. Same specificity as the styles.css rail rules and later
|
||||
in the cascade, which is why this file must stay loaded after styles.css. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-subagent-badge {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* mobile.css:~604 pins .session-tab to max-height:32px for the horizontal strip,
|
||||
which clips the folder row the sidebar always renders. Rows also need the
|
||||
44px touch target the strip cannot afford. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab {
|
||||
min-height: 44px;
|
||||
max-height: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Touch has no hover: reveal-on-hover row actions would be unreachable.
|
||||
Matches the (hover: none) block above, but has to be repeated here because
|
||||
the phone block hides them on non-active tabs with (0,2,0). */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
min-width: 28px;
|
||||
height: auto;
|
||||
margin-left: 0;
|
||||
padding: 0.15rem 0.25rem;
|
||||
}
|
||||
|
||||
/* (.tab-filtered-out is handled in styles.css — its rule is already scoped to
|
||||
html[data-session-list="sidebar"] and carries !important, so it wins here too;
|
||||
no handheld variant needed.) */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-session-list="sidebar"] .session-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1595,7 +1595,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
input.value = parsed ? parsed.suffix : (session.name || '');
|
||||
input.placeholder = parsed ? 'Add description...' : currentName;
|
||||
input.className = 'tab-rename-input';
|
||||
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
||||
// 80px is tuned for the narrow header tab; a full-width sidebar row can and
|
||||
// should give the whole line to the input.
|
||||
const renameWidth = this.isSessionSidebarActive?.() ? '100%' : '80px';
|
||||
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
|
||||
|
||||
tabName.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
@@ -363,6 +363,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
// Claude CLI settings
|
||||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||||
const allowedToolsRow = document.getElementById('allowedToolsRow');
|
||||
@@ -1542,6 +1544,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
skin: document.getElementById('appSettingsSkin').value,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
@@ -1683,7 +1686,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
// Re-parents #sessionTabs between header host and sidebar if the layout
|
||||
// changed, then calls applyTabWrapSettings() itself — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||
this.applyMonitorVisibility();
|
||||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||||
@@ -1910,6 +1915,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
sessionListLayout: 'header',
|
||||
cjkInputEnabled: false,
|
||||
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
|
||||
webglRendererEnabled: false, // mobile always uses the DOM renderer
|
||||
@@ -2133,19 +2139,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
const deviceType = MobileDetection.getDeviceType();
|
||||
// The left sidebar is one vertical column with its own scroller: there is no
|
||||
// row to wrap into, and its rows are always tall (name + folder) because that
|
||||
// is the cheapest way to tell 25 sessions apart. Header strip keeps the old
|
||||
// rules unchanged. Kept here rather than only in applySessionListLayout() so
|
||||
// that a stray applyTabWrapSettings() call (this one is invoked from
|
||||
// saveAppSettings and from the resize path) cannot leave the sidebar wrapped.
|
||||
const sidebar = this.isSessionSidebarActive?.() === true;
|
||||
// Two-row tabs disabled on mobile/tablet — not enough screen space
|
||||
const twoRows = deviceType === 'desktop'
|
||||
const twoRows = !sidebar && deviceType === 'desktop'
|
||||
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
|
||||
: false;
|
||||
const showFolder = sidebar || twoRows;
|
||||
const prevTallTabs = this._tallTabsEnabled;
|
||||
this._tallTabsEnabled = twoRows;
|
||||
this._tallTabsEnabled = showFolder;
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
if (tabsEl) {
|
||||
tabsEl.classList.toggle('tabs-two-rows', twoRows);
|
||||
tabsEl.classList.toggle('tabs-show-folder', twoRows);
|
||||
tabsEl.classList.toggle('tabs-show-folder', showFolder);
|
||||
}
|
||||
// Re-render tabs if folder visibility changed (folder spans are generated in JS)
|
||||
if (prevTallTabs !== undefined && prevTallTabs !== twoRows) {
|
||||
if (prevTallTabs !== undefined && prevTallTabs !== showFolder) {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
},
|
||||
@@ -2350,7 +2364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
|
||||
--header-height: 36px;
|
||||
--toolbar-height: 42px;
|
||||
--sidebar-width: 260px;
|
||||
--sidebar-width-collapsed: 44px; /* == --touch-target-min */
|
||||
--sidebar-transition: 0.18s ease;
|
||||
--glass-bg: rgba(31, 38, 48, 0.85);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--control-bg: rgba(255, 255, 255, 0.045);
|
||||
@@ -2049,6 +2052,9 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
|
||||
/* ===== Solo (detached single-session) window chrome ===================== */
|
||||
body.solo-mode .session-tabs,
|
||||
body.solo-mode .session-tabs-host,
|
||||
body.solo-mode .session-sidebar,
|
||||
body.solo-mode .btn-sidebar-toggle,
|
||||
body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
@@ -13462,3 +13468,244 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
/* Delete sits apart from Cancel/Save so it is not fat-fingered on the way to Save. */
|
||||
.webview-modal-actions { justify-content: space-between; }
|
||||
.webview-modal-actions .btn-danger { margin-right: auto; }
|
||||
|
||||
/* ============================================================
|
||||
=== Collapsible session sidebar (opt-in layout) ===
|
||||
Appended at top level ON PURPOSE: styles.css:12171-12390 is one
|
||||
html:not([data-skin="og"]) { … } native-nesting block whose bare
|
||||
selectors resolve at (0,2,x) and re-tone .session-tab with
|
||||
!important. Everything below is LAYOUT ONLY (flex/size/overflow/
|
||||
display) and sets no colour on .session-tab, so it composes with
|
||||
every skin instead of fighting it. Keep it that way.
|
||||
|
||||
The list itself is not a second DOM tree: applySessionListLayout()
|
||||
moves the one #sessionTabs element between #sessionTabsHost (header)
|
||||
and #sessionSidebarList (this aside).
|
||||
============================================================ */
|
||||
|
||||
/* Header host — wraps #sessionTabs so the strip can be hidden without
|
||||
touching the element that gets re-parented. */
|
||||
.session-tabs-host {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-tabs-host {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The header only needs flex-start to support the two-row strip; with the
|
||||
strip gone the remaining header chrome should sit centered. */
|
||||
html[data-session-list="sidebar"] .header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.session-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 var(--sidebar-width);
|
||||
width: var(--sidebar-width);
|
||||
min-width: 0;
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border);
|
||||
/* Own stacking context ABOVE .welcome-overlay (z-index 10, which is what a
|
||||
user with no open session sees) but BELOW .toolbar (20) — raising it to or
|
||||
past 20 makes the Run menu unclickable again. */
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
transition: flex-basis var(--sidebar-transition), width var(--sidebar-transition);
|
||||
/* Deliberately NO contain:paint — .header has it, which is exactly why app.js
|
||||
re-parents .subagent-dropdown to <body>. Leaving it off keeps per-row
|
||||
dropdowns and the inline rename input paintable in place. */
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar {
|
||||
flex-basis: var(--sidebar-width-collapsed);
|
||||
width: var(--sidebar-width-collapsed);
|
||||
}
|
||||
|
||||
.session-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-sidebar-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.session-sidebar-filter {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.session-sidebar-filter-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.3rem 0.45rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: var(--btn-radius);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.session-sidebar-filter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-sidebar-filter-input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Host for the relocated #sessionTabs. */
|
||||
.session-sidebar-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- The relocated strip, now vertical --------------------------------- */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tabs {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
flex-wrap: nowrap;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: var(--btn-radius);
|
||||
}
|
||||
|
||||
/* .tab-info is already column/overflow-hidden/min-width:0 — it only has to
|
||||
claim the free width now that rows are full-width. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .tab-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .tab-name {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row
|
||||
actions permanently visible on the active session — no layout jitter when
|
||||
the pointer crosses the list. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-gear,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-detach,
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-close {
|
||||
opacity: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Drag-reorder indicators become horizontal edges. The class names stay
|
||||
drag-over-left / drag-over-right (they read as before/after now) so app.js,
|
||||
the base rules above and the generated gesture bundle need no renaming. */
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-left {
|
||||
box-shadow: 0 -2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"] .session-sidebar .session-tab.drag-over-right {
|
||||
box-shadow: 0 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* Sidebar filter box (applySidebarFilter toggles this class post-render).
|
||||
Scoped to the sidebar layout on purpose: applySidebarFilter() already strips
|
||||
the class whenever the filter box is off screen, and this prefix is the
|
||||
second lock — a leaked class must never be able to hide tabs from the header
|
||||
strip, which has no filter control to clear it with. */
|
||||
html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- Collapsed rail ---------------------------------------------------- */
|
||||
/* Collapsed is a 44px icon rail, not "hidden": the ambient signal (status dot,
|
||||
task/subagent/ultracode badges) is the whole point of mission control and
|
||||
must survive collapse. The rail is also its own reopen affordance — clicking
|
||||
a row still switches session.
|
||||
NOT surviving: the name, the folder and the `sh`/`oc`/`cx`/`gm` mode chip —
|
||||
the chip is rendered inside .tab-info (app.js row template), which the rail
|
||||
hides. Moving it out of .tab-info just to keep it would change the shared row
|
||||
markup for both layouts; agent type stays a hover/expand affordance. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-head,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 44px rail minus the list's 0.25rem padding either side minus the row's 1px
|
||||
borders leaves ~34px of content box. Number (16) + gap (5.6) + dot (6) + gap
|
||||
(5.6) + one badge (16) already overflows that, and .tab-number / .tab-status
|
||||
are flex-shrink: 0 — with justify-content: center the excess gets clipped at
|
||||
BOTH ends, so the digit and the badge are cut in half. Two fixes, both
|
||||
needed: drop the Alt+N hint (it is a keyboard affordance that only reads in
|
||||
the expanded list; Alt+N itself keeps working), and let whatever is left wrap
|
||||
instead of clipping, so a row carrying several badges just gets taller. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .session-tab {
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-gear,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-detach,
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The subagent badge carries a 4px left margin tuned for the horizontal strip;
|
||||
in a centered 34px rail it pushes the row off-centre. */
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-subagent-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* --- Toggle button ----------------------------------------------------- */
|
||||
.btn-sidebar-toggle--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* .btn-icon-header:hover rotates 45deg globally — a panel glyph must not spin. */
|
||||
.btn-sidebar-toggle:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,15 +401,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Draw curved line from TAB bottom-center to window top-center
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
|
||||
// Bezier curve control points for smooth curve
|
||||
const midY = (y1 + y2) / 2;
|
||||
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
// Draw a curved line from the tab to the window. Header strip: tab
|
||||
// bottom-center → window top-center (vertical). Sidebar: tab right-edge →
|
||||
// window left-edge (horizontal), otherwise the curve loops backwards
|
||||
// underneath the sidebar. _tabAnchor/_tabConnectorPath live in app.js.
|
||||
const anchor = this._tabAnchor(tabRect);
|
||||
const path = this._tabConnectorPath(anchor, winRect);
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
@@ -744,9 +741,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
win.style.top = `${finalY}px`;
|
||||
win.style.bottom = 'auto';
|
||||
} else if (flyFromTab) {
|
||||
const tabRect = parentTab.getBoundingClientRect();
|
||||
win.style.left = `${tabRect.left}px`;
|
||||
win.style.top = `${tabRect.bottom}px`;
|
||||
// Spawn at the tab: below it in header layout, to its RIGHT in sidebar
|
||||
// layout — spawning at tabRect.left there would land on top of the sidebar.
|
||||
const anchor = this._tabAnchor(parentTab.getBoundingClientRect());
|
||||
win.style.left = `${anchor.spawnLeft}px`;
|
||||
win.style.top = `${anchor.spawnTop}px`;
|
||||
win.style.transform = 'scale(0.3)';
|
||||
win.style.opacity = '0';
|
||||
win.classList.add('spawning');
|
||||
@@ -1221,6 +1220,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||||
dropdown.style.transform = 'translateX(-50%)';
|
||||
dropdown.classList.add('open');
|
||||
|
||||
// Keep it on screen. A badge in the left sidebar — and above all one in the
|
||||
// 44px collapsed rail — sits so far left that a centre-anchored dropdown
|
||||
// hangs off the viewport. Measured after .open so it has a box; a no-op
|
||||
// whenever the centred position already fits, so header layout is unchanged.
|
||||
const dropRect = dropdown.getBoundingClientRect();
|
||||
const overflowLeft = 8 - dropRect.left;
|
||||
const overflowRight = dropRect.right - (window.innerWidth - 8);
|
||||
if (overflowLeft > 0) {
|
||||
dropdown.style.transform = `translateX(calc(-50% + ${Math.round(overflowLeft)}px))`;
|
||||
} else if (overflowRight > 0) {
|
||||
dropdown.style.transform = `translateX(calc(-50% - ${Math.round(overflowRight)}px))`;
|
||||
}
|
||||
},
|
||||
|
||||
// Schedule hide after delay (allows moving mouse to dropdown)
|
||||
|
||||
@@ -182,6 +182,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Session-sidebar toggle chord (default Alt+B): same trap as above —
|
||||
// preventDefault() in the capture handler does not stop xterm, so without
|
||||
// this gate every toggle would ALSO send ESC b (readline backward-word)
|
||||
// into the live session and walk the cursor back through the user's
|
||||
// half-typed prompt. Registry-aware and only while the sidebar layout is
|
||||
// active, so a rebind/disable and the default header layout keep plain
|
||||
// Meta-b working in the terminal.
|
||||
if (ev.type === 'keydown' && this.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V: intercept before xterm sends ^V to PTY.
|
||||
// Route through our paste trap which handles both images and text.
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||
|
||||
@@ -197,10 +197,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Position: spawn from the parent tab if we can find it, else cascade.
|
||||
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
|
||||
if (parentTab) {
|
||||
const r = parentTab.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(r.left, window.innerWidth - 392));
|
||||
// _tabAnchor() puts the spawn point below the tab in header layout and to
|
||||
// the RIGHT of it in sidebar layout, so the window never lands on the
|
||||
// sidebar. The viewport clamp is unchanged.
|
||||
const anchor = this._tabAnchor(parentTab.getBoundingClientRect());
|
||||
const left = Math.max(8, Math.min(anchor.spawnLeft, window.innerWidth - 392));
|
||||
win.style.left = `${left}px`;
|
||||
win.style.top = `${r.bottom + 14}px`;
|
||||
win.style.top = `${anchor.spawnTop + (anchor.vertical ? 14 : 0)}px`;
|
||||
} else {
|
||||
const n = this.ultracodeWindows.size;
|
||||
win.style.left = `${24 + n * 26}px`;
|
||||
@@ -784,16 +787,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
|
||||
}
|
||||
|
||||
// PHASE 2: writes (curve from tab bottom-center to window top-center).
|
||||
// PHASE 2: writes (curve from the tab anchor to the window — bottom-center to
|
||||
// top-center in header layout, right-edge to left-edge in sidebar layout).
|
||||
for (const { runId, parentSessionId, winRect } of winList) {
|
||||
const tabRect = rects.get('tab:' + parentSessionId);
|
||||
if (!tabRect) continue;
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (y1 + y2) / 2;
|
||||
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
const path = this._tabConnectorPath(this._tabAnchor(tabRect), winRect);
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line ultracode-connection');
|
||||
@@ -818,12 +817,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!info.element) continue;
|
||||
const winRect = info.element.getBoundingClientRect();
|
||||
// Anchor: parent run window bottom-center if open, else the run's tab.
|
||||
let px, py;
|
||||
// A window anchor is always vertical; a tab anchor follows the session-list
|
||||
// layout (_tabAnchor), so the curve leaves a sidebar row sideways.
|
||||
let anchor;
|
||||
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
|
||||
if (runWin && runWin.element) {
|
||||
const pr = runWin.element.getBoundingClientRect();
|
||||
px = pr.left + pr.width / 2;
|
||||
py = pr.bottom;
|
||||
anchor = { x: pr.left + pr.width / 2, y: pr.bottom, vertical: true };
|
||||
} else {
|
||||
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
|
||||
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
|
||||
@@ -835,13 +835,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
const tabRect = rects.get(tabKey);
|
||||
if (!tabRect) continue;
|
||||
px = tabRect.left + tabRect.width / 2;
|
||||
py = tabRect.bottom;
|
||||
anchor = this._tabAnchor(tabRect);
|
||||
}
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (py + y2) / 2;
|
||||
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
const path = this._tabConnectorPath(anchor, winRect);
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
|
||||
|
||||
@@ -157,6 +157,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
this.renderSessionTabs();
|
||||
this._updateActiveWebviewTab();
|
||||
// Web tabs live in the same list as sessions, so picking one from the
|
||||
// handheld session drawer has to dismiss it too (no-op elsewhere).
|
||||
this.closeSessionSidebarOnHandheld?.();
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
|
||||
@@ -766,6 +766,8 @@ export const SettingsUpdateSchema = z
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/** Session list layout: 'header' = horizontal tab strip, 'sidebar' = collapsible left sidebar. Display key (per-device). */
|
||||
sessionListLayout: z.enum(['header', 'sidebar']).optional(),
|
||||
agentTeamsEnabled: z.boolean().optional(),
|
||||
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
|
||||
claudeModel: z.string().max(50).optional(),
|
||||
|
||||
Reference in New Issue
Block a user