Merge remote-tracking branch 'origin/master' into feat/readmymind-phase3-part1

# Conflicts:
#	CLAUDE.md
#	src/web/public/home-sessions.js
This commit is contained in:
Codeman maintainer
2026-08-10 04:30:33 +02:00
30 changed files with 4321 additions and 931 deletions
+35 -20
View File
@@ -110,34 +110,49 @@
}
// ── Admin Users panel (injected into the App Settings modal) ──────────────
// The settings modal is a rail (table of contents) over ONE scrolling
// document, so this appends a rail entry plus a real section rather than a
// tab button plus a hidden panel.
function injectUsersTab() {
const modal = document.getElementById('appSettingsModal');
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
const tabs = modal.querySelector('.modal-tabs');
const body = modal.querySelector('.modal-body');
if (!tabs || !body) return;
if (!modal || modal.querySelector('[data-section="settings-users"]')) return;
const rail = modal.querySelector('.set-rail-items');
const body = modal.querySelector('.set-doc');
if (!rail || !body) return;
const btn = document.createElement('button');
btn.className = 'modal-tab-btn';
btn.dataset.tab = 'settings-users';
btn.textContent = 'Users';
tabs.appendChild(btn);
const content = document.createElement('div');
content.className = 'modal-tab-content hidden';
btn.type = 'button';
btn.className = 'set-rail-item';
btn.dataset.section = 'settings-users';
btn.innerHTML =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg><span>Users</span>';
rail.appendChild(btn);
const content = document.createElement('section');
content.className = 'set-section';
content.id = 'settings-users';
content.dataset.label = 'Users';
content.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<span>
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
</span>
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
<h2>Users</h2>
</div>
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
<p class="set-section-blurb">Users share the host account; this separates workspaces, it does not sandbox
users from each other. Pair with Docker cases for isolation.</p>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
<div class="set-group">
<div class="set-group-head"><h4>Accounts</h4></div>
<div class="set-group-body">
<div class="set-row">
<div class="set-row-text"><span class="set-row-label">Manage users</span></div>
<div class="set-row-actions">
<button class="btn-toolbar btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn-toolbar btn-sm" id="adminAddUser">+ Add user</button>
</div>
</div>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--text-muted)"></p>
</div>
</div>`;
body.appendChild(content);
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
// Render whenever the entry is used (the shared switchSettingsTab scrolls to it).
btn.addEventListener('click', renderUsers);
content.querySelector('#adminAddUser').onclick = addUserFlow;
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
+82 -8
View File
@@ -3461,6 +3461,54 @@ class CodemanApp {
tab.classList.remove('active');
}
}
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId);
}
/**
* Scroll the tab strip so the given (default: active) tab is visible.
*
* Only phones/tablets scroll the strip (desktop wraps to a second row), and
* the pure policy no-ops whenever there is nothing to scroll, so this is a
* cheap call on every device.
*
* Deliberately NOT scrollIntoView(): that also scrolls every scrollable
* ANCESTOR, which on a phone is the document itself. With the header fixed
* and the keyboard possibly open, a vertical nudge there shifts the whole
* app. Rect math + scrollLeft touches exactly one scroller.
*/
_scrollActiveTabIntoView(sessionId, behavior = 'smooth') {
const container = this.$('sessionTabs');
if (!container) return;
const tab =
(sessionId && container.querySelector(`.session-tab[data-id="${sessionId}"]`)) ||
container.querySelector('.session-tab.active');
if (!tab) return;
const policy = window.CodemanTabOverflow?.computeTabScrollLeft;
if (!policy) return;
const containerRect = container.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
const target = policy({
scrollLeft: container.scrollLeft,
clientWidth: container.clientWidth,
scrollWidth: container.scrollWidth,
// Offsets are relative to the SCROLL CONTENT, not the offsetParent: the
// tabs' offsetParent is the positioned header, so offsetLeft would carry
// the brand column's width into the math.
tabLeft: tabRect.left - containerRect.left + container.scrollLeft,
tabWidth: tabRect.width,
});
if (Math.abs(target - container.scrollLeft) < 1) return;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ left: target, behavior: reduceMotion ? 'auto' : behavior });
} else {
container.scrollLeft = target;
}
}
_setTerminalLoadState(sessionId, selectGen, phase) {
@@ -3678,6 +3726,11 @@ class CodemanApp {
this._fullRenderSessionTabs();
}
// Keep the reveal-on-change bookkeeping honest when only the incremental
// branch ran: _updateActiveTabImmediate has already scrolled the new active
// tab into view, so the next full rebuild must not treat it as a change.
this._lastRenderedActiveTabId = this.activeSessionId;
this.updateTabOverflowMode();
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
// so measuring mid-animation would decide the wrap on collapsed widths.
@@ -3749,15 +3802,25 @@ class CodemanApp {
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)
// #257: replacing innerHTML below resets scrollLeft to 0. On phones the
// strip scrolls, and ambient rebuilds (a task badge appearing, a session
// created elsewhere) fire often enough that a user swiping toward the
// right-hand tabs kept getting yanked back to the first one. Remember
// where the strip was; the browser clamps the restore to the new content.
const prevScrollLeft = container.scrollLeft;
const prevActiveTabId = this._lastRenderedActiveTabId;
const isFirstRender = !container.querySelector('.session-tab');
// Build tabs HTML using array for better string concatenation performance.
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
// EVERY device: mobile used to hoist the active session to the front, from
// when only one tab fit on screen. With five tabs it made the strip jump
// under the user's finger (and renumbered the Alt+N badges) on every full
// rebuild, while the incremental path left the order alone, so the order
// depended on which render path happened to run. Scrolling the active tab
// into view replaces it.
const parts = [];
let tabOrder = this.sessionOrder;
if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) {
// Reorder to put active tab first
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
}
const tabOrder = this.sessionOrder;
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -3826,6 +3889,17 @@ class CodemanApp {
container.innerHTML = parts.join('');
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
// and revealing conditionally is what lets someone browse the far end of
// the strip while a background rebuild fires, without the active tab ever
// being stranded off-screen after a switch.
container.scrollLeft = prevScrollLeft;
this._lastRenderedActiveTabId = this.activeSessionId;
if (isFirstRender || prevActiveTabId !== this.activeSessionId) {
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
}
// Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers();
+43
View File
@@ -156,6 +156,47 @@ function shouldAutoWrapTabs(input) {
return scrollWidth > clientWidth + 1;
}
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into
// view. Landing a tab flush against the edge reads as "this is the last one";
// the gap is what tells the user there is more strip to swipe to.
const TAB_SCROLL_REVEAL_PX = 16;
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
// the strip horizontally (desktop wraps to a second row instead and never
// scrolls), so the active tab can sit entirely outside the visible slice with
// no way back except a swipe the user may not know is possible.
//
// Returns the scrollLeft that puts the tab inside the window, clamped to the
// scrollable range, and returns the CURRENT scrollLeft when the tab is already
// visible: callers compare and skip the write, so an already-correct strip is
// never nudged. Pure: the caller measures, this decides.
function computeTabScrollLeft(input) {
const scrollWidth = Number(input?.scrollWidth) || 0;
const clientWidth = Number(input?.clientWidth) || 0;
const maxScroll = Math.max(0, scrollWidth - clientWidth);
if (maxScroll === 0 || clientWidth <= 0) return 0;
const pad = input?.padding == null ? TAB_SCROLL_REVEAL_PX : Number(input.padding) || 0;
const tabLeft = Number(input?.tabLeft) || 0;
const tabWidth = Number(input?.tabWidth) || 0;
const tabRight = tabLeft + tabWidth;
const viewLeft = Math.min(Math.max(Number(input?.scrollLeft) || 0, 0), maxScroll);
const viewRight = viewLeft + clientWidth;
let target = viewLeft;
if (tabWidth + pad >= clientWidth) {
// Tab is as wide as the window (long session name on a narrow phone):
// there is no position that shows all of it plus padding, so align its
// start, since the name matters more than the trailing badges.
target = tabLeft;
} else if (tabLeft - pad < viewLeft) {
target = tabLeft - pad;
} else if (tabRight + pad > viewRight) {
target = tabRight + pad - clientWidth;
}
return Math.min(Math.max(Math.round(target), 0), maxScroll);
}
// COD-134 — Terminal WebSocket reconnect policy.
//
// Decide what to do after a terminal WebSocket closes, given the close `code`
@@ -261,6 +302,8 @@ if (typeof window !== 'undefined') {
window.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
computeTabScrollLeft,
TAB_SCROLL_REVEAL_PX,
};
window.CodemanWsReconnect = {
plan: planWsReconnect,
+1 -1
View File
@@ -43,7 +43,7 @@
/**
* Narrowest window that gets the rail. The welcome content is 560px wide and
* centered, so at 1180px each gutter is 310px — enough for the rail at its
* centered, so at 1180px each gutter is 310px, enough for the rail at its
* 250px floor and still a visible gap. Anything narrower would overlap the
* search panel, which is why this is a width gate and not a device-type gate.
*/
+850 -771
View File
File diff suppressed because it is too large Load Diff
+369 -1
View File
@@ -115,13 +115,17 @@ html.mobile-init .file-browser-panel {
}
/* Compact session tabs — .tabs-two-rows override needed to match
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0).
overscroll-behavior-x keeps a swipe that runs past the last tab inside the
strip: chained to the page it becomes the browser's back gesture, which is
exactly the swipe someone makes reaching for the rightmost tabs (#257). */
.session-tabs,
.session-tabs.tabs-two-rows {
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
max-height: 52px;
gap: 3px;
@@ -644,6 +648,7 @@ html.mobile-init .file-browser-panel {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
max-height: 36px;
gap: 2px;
@@ -681,6 +686,13 @@ html.mobile-init .file-browser-panel {
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent) !important;
}
/* No orbiting ring on phone tabs (styles.css draws one on desktop/tablet):
the dot is already enlarged to 9px with a glow here, and a 15px ring in a
32px tab would sit on top of the tab name. The glow is the phone's tell. */
.session-tab .tab-status.busy::after {
display: none;
}
/* Truncate tab names more aggressively on mobile */
.session-tab .tab-name {
max-width: 50px;
@@ -3127,3 +3139,359 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
padding: 0.65rem 1rem;
}
}
/* ============================================================================
App Settings, compact layout (<= 860px)
Same single scrolling document as the desktop rail layout; only the
navigation changes. The rail collapses to its search field and #appSettingsJump
takes over as the sticky "where am I / jump elsewhere" control, so a phone
spends its vertical budget on settings instead of on chrome.
Groups render as one inset rounded list with hairline dividers rather than a
stack of separate cards: at 390px the per-card borders were most of the pixels.
============================================================================ */
@media (max-width: 860px) {
#appSettingsModal .modal-content.modal-lg {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
}
#appSettingsModal .set-body {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Rail keeps only its search field, laid out as a bar */
#appSettingsModal .set-rail {
flex-direction: row;
align-items: center;
border-right: 0;
border-bottom: 1px solid var(--border);
background: transparent;
padding: 10px 14px;
overflow: visible;
flex-shrink: 0;
}
#appSettingsModal .set-rail-items,
#appSettingsModal .set-rail-foot {
display: none;
}
#appSettingsModal .set-search {
margin: 0;
flex: 1;
}
#appSettingsModal .set-search input {
padding: 9px 10px 9px 30px;
border-radius: 10px;
}
/* Save moves into the header; the bottom action bar would cost 60px */
#appSettingsModal .set-head-save {
display: inline-flex;
}
#appSettingsModal .set-foot {
display: none;
}
#appSettingsModal .set-doc {
padding: 0 14px 34px;
flex: 1;
}
/* ── jump control ──────────────────────────────────────────────────── */
#appSettingsModal .set-jump {
display: flex;
position: sticky;
top: 0;
z-index: 4;
align-items: center;
gap: 8px;
width: 100%;
margin: 10px 0 2px;
padding: 10px 12px;
border-radius: 11px;
font: inherit;
font-size: 0.82rem;
color: var(--text);
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.3);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
cursor: pointer;
}
#appSettingsModal .set-jump-ico {
color: var(--accent);
flex-shrink: 0;
}
#appSettingsModal .set-jump-label {
font-weight: 580;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#appSettingsModal .set-jump-chev {
margin-left: auto;
color: var(--text-muted);
flex-shrink: 0;
transition: transform 0.18s;
}
#appSettingsModal .set-jump[aria-expanded='true'] .set-jump-chev {
transform: rotate(180deg);
}
#appSettingsModal .set-jump-veil {
display: none;
position: fixed;
inset: 0;
z-index: 8;
background: rgba(4, 8, 13, 0.62);
}
#appSettingsModal .set-jump-menu {
display: none;
position: absolute;
left: 14px;
right: 14px;
z-index: 9;
padding: 7px;
border-radius: 16px;
/* Opaque on purpose: --floating-bg is translucent and the settings rows
behind the menu bleed through it. */
background: var(--bg-card);
border: 1px solid var(--control-border);
box-shadow: var(--elevated-shadow);
max-height: 70vh;
overflow-y: auto;
}
#appSettingsModal.jump-open .set-jump-veil,
#appSettingsModal.jump-open .set-jump-menu {
display: block;
}
#appSettingsModal .set-jump-row {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 11px 12px;
border: 0;
border-radius: 11px;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.82rem;
text-align: left;
}
#appSettingsModal .set-jump-row svg {
color: var(--text-muted);
flex-shrink: 0;
}
#appSettingsModal .set-jump-row .set-jump-count {
margin-left: auto;
font-size: 0.62rem;
color: var(--text-muted);
}
#appSettingsModal .set-jump-row.active {
background: rgba(var(--accent-rgb), 0.14);
color: var(--text);
font-weight: 570;
}
#appSettingsModal .set-jump-row.active svg {
color: var(--accent);
}
/* ── sections step down: the jump pill already names the current one ── */
#appSettingsModal .set-section {
padding-top: 0;
}
#appSettingsModal .set-section + .set-section {
border-top: 0;
margin-top: 0;
}
#appSettingsModal .set-section-head {
gap: 7px;
margin: 18px 0 2px;
}
#appSettingsModal .set-section-head svg {
padding: 0;
border: 0;
background: none;
color: var(--text-muted);
width: 12px;
height: 12px;
}
#appSettingsModal .set-section-head h2 {
font-size: 0.6rem;
font-weight: 640;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
#appSettingsModal .set-section-head::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
}
#appSettingsModal .set-section-blurb {
display: none;
}
/* ── inset grouped list ────────────────────────────────────────────── */
#appSettingsModal .set-group {
margin-top: 14px;
}
#appSettingsModal .set-group + .set-group {
margin-top: 16px;
}
#appSettingsModal .set-group-head {
margin-bottom: 7px;
padding: 0 3px;
}
#appSettingsModal .set-group-hint {
padding: 0 3px;
}
#appSettingsModal .set-group-body {
gap: 0;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 13px;
overflow: hidden;
}
#appSettingsModal .set-group-body > .set-row {
background: transparent;
border: 0;
border-radius: 0;
padding: 12px 13px;
gap: 12px;
}
#appSettingsModal .set-group-body > .set-row + .set-row {
border-top: 1px solid rgba(255, 255, 255, 0.055);
}
#appSettingsModal .set-group-body > .set-chips,
#appSettingsModal .set-group-body > .set-modelgrid,
#appSettingsModal .set-group-body > .set-minigrid,
#appSettingsModal .set-group-body > #appSettingsShortcutsList {
padding: 12px;
}
#appSettingsModal .set-group-body > .event-type-grid {
padding: 12px;
margin: 0;
}
#appSettingsModal .set-row-label {
font-size: 0.84rem;
}
#appSettingsModal .set-row-desc {
font-size: 0.69rem;
max-width: none;
}
/* Fields go full width under their label instead of fighting for the row */
#appSettingsModal .set-row.has-field {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
#appSettingsModal .set-row.has-field .set-select,
#appSettingsModal .set-row.has-field .set-input {
width: 100%;
min-width: 0;
max-width: none;
box-sizing: border-box;
}
#appSettingsModal .set-row-actions-wide {
width: 100%;
}
#appSettingsModal .set-row-actions-wide .set-input {
flex: 1;
min-width: 0;
}
#appSettingsModal .set-num {
width: 76px;
}
/* Bigger touch targets for the toggles and chips */
#appSettingsModal .switch-sm {
width: 40px;
height: 24px;
}
#appSettingsModal .switch-sm .slider:before {
height: 18px;
width: 18px;
}
#appSettingsModal .switch-sm input:checked + .slider:before {
transform: translateX(16px);
}
#appSettingsModal .set-chip {
font-size: 0.78rem;
padding: 9px 14px;
}
#appSettingsModal .set-modelgrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
#appSettingsModal .set-minigrid {
grid-template-columns: 1fr;
}
#appSettingsModal .set-mini .set-select {
width: 148px;
}
/* One scrollable line beats a ragged two-row wrap for 7 effort levels */
#appSettingsModal .set-segment {
overflow-x: auto;
scrollbar-width: none;
}
#appSettingsModal .set-segment::-webkit-scrollbar {
display: none;
}
#appSettingsModal .set-segment button {
flex: 0 0 auto;
padding: 8px 12px;
}
}
+5
View File
@@ -649,6 +649,11 @@ Object.assign(CodemanApp.prototype, {
sizeBytes: s.sizeBytes ?? 0,
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
firstPrompt: s.firstPrompt || s.name || '',
// Must be carried explicitly: this record is a re-projection, so any
// field omitted here silently vanishes from the Cmd+K list (#266).
gitBranch: s.gitBranch,
worktreeName: s.worktreeName,
worktreeRepo: s.worktreeRepo,
};
const isLive = !!this.sessions?.has?.(s.sessionId);
const item = this._buildHistoryItem(record, this.cases, {
+348 -33
View File
@@ -354,11 +354,8 @@ Object.assign(CodemanApp.prototype, {
// Phone overview home screen: only meaningful under 430px, so the row is
// hidden elsewhere rather than offering a toggle that changes nothing.
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
const phoneSection = document.getElementById('appSettingsPhoneSection');
if (phoneSection) phoneSection.style.display = phoneOnly;
if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
// in the template, so an unchecked box and a hidden button stay consistent).
@@ -500,12 +497,15 @@ Object.assign(CodemanApp.prototype, {
// Updates section — show current version, reset transient result/progress UI.
this._initUpdatesSection();
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
// Model cards + effort segment are views over the hidden <select>s above,
// so they must be synced AFTER those have been given their stored values.
this._initSettingsNav();
this._syncSettingsChips();
this._syncModelCards();
this._syncEffortSegment();
// Back to the top of the document (one scroll, not a tab reset).
this.switchSettingsTab('settings-terminal');
const modal = document.getElementById('appSettingsModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchSettingsTab(btn.dataset.tab);
});
modal.classList.add('active');
// Activate focus trap
@@ -514,44 +514,359 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Show the App Settings "Codex CLI" tab only on instances where the codex
* binary actually resolves. Both settings on it (approval bypass, animated
* status effects) are passed to `codex` at launch, so on a box without codex
* the tab is a promise nothing can keep.
* Show the App Settings "Codex" group only on instances where the codex binary
* actually resolves. Both settings in it (approval bypass, animated status
* effects) are passed to `codex` at launch, so on a box without codex the
* group is a promise nothing can keep.
*
* Availability comes from the injected `window.__codemanCliAvailable`, shared
* with the welcome buttons and the run-mode dropdown, so the tab never flickers
* in and back out. Only the tab BUTTON is toggled: the panel already carries
* `.modal-tab-content.hidden` unless it is the selected tab, and
* openAppSettings() always reopens on Display, so an unreachable button is
* enough to keep the panel unreachable.
* with the welcome buttons and the run-mode dropdown, so the group never
* flickers in and back out. The inputs stay in the DOM either way, so a user
* without codex can never silently wipe the codex prefs of an instance that
* has it (openAppSettings/saveAppSettings still read and write them).
*
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
* tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
* and are still saved), whereas hiding a run button would leave a working
* install with nothing to click.
* group. Hiding it costs a user nothing, whereas hiding a run button would
* leave a working install with nothing to click.
*/
_applyCodexSettingsVisibility() {
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
const group = document.getElementById('appSettingsCodexGroup');
if (group) group.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
},
switchSettingsTab(tabName) {
/**
* Scroll the settings document to a section.
*
* Kept under the historical `switchSettingsTab` name because it is the shared
* entry point: openAppSettings() calls it, and admin-ui.js's injected Users
* entry routes through it too. Sections are never hidden any more — the rail
* is a table of contents over ONE document, so "switching" is a scroll.
*/
switchSettingsTab(sectionId) {
// The Shortcuts list renders lazily so it reflects the CURRENT registry
// (defaults + overrides) every time it is reached.
if (sectionId === 'settings-shortcuts') this.renderShortcutSettingsList?.();
const doc = document.getElementById('appSettingsDoc');
const section = document.getElementById(sectionId);
if (doc && section && typeof section.offsetTop === 'number') {
// On phones the jump pill is sticky at the top of the document, so land
// the section head below it instead of underneath it.
const jump = document.getElementById('appSettingsJump');
const inset = jump && jump.offsetParent ? jump.offsetHeight + 16 : 6;
doc.scrollTop = Math.max(0, section.offsetTop - inset);
}
this._setActiveSettingsSection(sectionId);
},
/** Paint the rail + jump pill for the section currently in view. */
_setActiveSettingsSection(sectionId) {
const modal = document.getElementById('appSettingsModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
if (!modal || typeof modal.querySelectorAll !== 'function') return;
let active = null;
modal.querySelectorAll('.set-rail-item').forEach(item => {
const on = item.dataset.section === sectionId;
item.classList.toggle('active', on);
if (on) active = item;
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
modal.querySelectorAll('.set-jump-row').forEach(row => {
row.classList.toggle('active', row.dataset.section === sectionId);
});
// The Shortcuts tab renders lazily so the list reflects the CURRENT
// registry (defaults + overrides) every time it is opened.
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
const label = document.getElementById('appSettingsJump')?.querySelector('.set-jump-label');
if (label && active) label.textContent = active.textContent.trim();
const ico = document.getElementById('appSettingsJump')?.querySelector('.set-jump-ico');
const src = active?.querySelector('svg');
if (ico && src) ico.innerHTML = src.innerHTML;
},
/**
* Wire the settings navigation once per page: rail clicks, the phone jump
* menu, scroll-spy, live search, chip/card/segment views over the real inputs,
* and the collapsible Advanced group. Idempotent — openAppSettings() calls it
* on every open, and re-registering listeners on every open would multiply
* them across a long-lived tab.
*/
_initSettingsNav() {
const modal = document.getElementById('appSettingsModal');
const doc = document.getElementById('appSettingsDoc');
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards();
this._buildEffortSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu();
if (modal.dataset.navReady === '1') return;
modal.dataset.navReady = '1';
// Delegated so rail entries injected later (Users) work without rewiring.
modal.querySelector('.set-rail-items')?.addEventListener('click', e => {
const item = e.target.closest?.('.set-rail-item');
if (item?.dataset.section) this.switchSettingsTab(item.dataset.section);
});
document.getElementById('appSettingsJumpMenu')?.addEventListener('click', e => {
const row = e.target.closest?.('.set-jump-row');
if (!row?.dataset.section) return;
this._toggleSettingsJump(false);
this.switchSettingsTab(row.dataset.section);
});
document.getElementById('appSettingsJump')?.addEventListener('click', () => this._toggleSettingsJump());
document.getElementById('appSettingsJumpVeil')?.addEventListener('click', () => this._toggleSettingsJump(false));
// Scroll-spy: the rail follows the document rather than driving it.
doc.addEventListener('scroll', () => {
if (this._settingsSpyQueued) return;
this._settingsSpyQueued = true;
requestAnimationFrame(() => {
this._settingsSpyQueued = false;
const sections = [...doc.querySelectorAll('.set-section')].filter(s => s.offsetParent !== null);
if (!sections.length) return;
let current = sections[0].id;
for (const s of sections) {
if (s.offsetTop - doc.scrollTop <= 140) current = s.id;
}
this._setActiveSettingsSection(current);
});
});
const search = document.getElementById('appSettingsSearch');
search?.addEventListener('input', () => this._filterSettings(search.value));
// Chips are labels wrapping the real checkbox; mirror the checked state onto
// the label so the styling does not depend on :has() support.
modal.querySelectorAll('.set-chip input').forEach(input => {
input.addEventListener('change', () => this._syncSettingsChips());
});
const advHead = modal.querySelector('.set-group-head-toggle');
const advGroup = advHead?.closest('.set-group-advanced');
if (advHead && advGroup) {
const toggle = () => {
const open = advGroup.classList.toggle('open');
advHead.setAttribute('aria-expanded', open ? 'true' : 'false');
};
advHead.addEventListener('click', toggle);
advHead.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
}
document.getElementById('appSettingsOpusContext1m')?.addEventListener('change', () => this._applyModelSelection());
},
/** Phone jump menu, mirrored from the rail so the two can never drift. */
_buildSettingsJumpMenu() {
const modal = document.getElementById('appSettingsModal');
const menu = document.getElementById('appSettingsJumpMenu');
if (!modal || !menu) return;
menu.innerHTML = '';
modal.querySelectorAll('.set-rail-item').forEach(item => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'set-jump-row';
row.dataset.section = item.dataset.section;
row.innerHTML = item.innerHTML;
const section = document.getElementById(item.dataset.section);
const count = section ? section.querySelectorAll('input, select').length : 0;
if (count) {
const n = document.createElement('span');
n.className = 'set-jump-count';
n.textContent = String(count);
row.appendChild(n);
}
menu.appendChild(row);
});
},
_toggleSettingsJump(force) {
const modal = document.getElementById('appSettingsModal');
if (!modal) return;
const open = force === undefined ? !modal.classList.contains('jump-open') : force;
modal.classList.toggle('jump-open', open);
document.getElementById('appSettingsJump')?.setAttribute('aria-expanded', open ? 'true' : 'false');
},
/** Mirror checkbox state onto the chip labels (see _initSettingsNav). */
_syncSettingsChips() {
document.querySelectorAll('#appSettingsModal .set-chip').forEach(chip => {
chip.classList.toggle('is-on', !!chip.querySelector('input')?.checked);
});
},
/**
* Build the model picker cards from the hidden <select>'s own options, so the
* select stays the single source of truth that openAppSettings/saveAppSettings
* read and write by id. The `[1m]` variants are folded away: context width is a
* property of the chosen model (the "1M context window" switch), not a rival
* setting that silently loses to it.
*/
_buildModelCards() {
const select = document.getElementById('appSettingsClaudeModel');
const grid = document.getElementById('appSettingsModelCards');
if (!select || !grid || grid.dataset.built === '1' || !select.options) return;
grid.innerHTML = '';
[...select.options]
.filter(opt => opt.dataset.variant !== '1m')
.forEach(opt => {
const card = document.createElement('button');
card.type = 'button';
card.className = 'set-modelcard';
card.setAttribute('role', 'radio');
card.dataset.value = opt.value;
if (opt.dataset.ctx === '1') card.dataset.ctx = '1';
const top = document.createElement('span');
top.className = 'set-mc-top';
const name = document.createElement('span');
name.className = 'set-mc-name';
name.textContent = opt.textContent;
top.appendChild(name);
const dot = document.createElement('span');
dot.className = 'set-mc-dot';
top.appendChild(dot);
card.appendChild(top);
const meta = document.createElement('span');
meta.className = 'set-mc-meta';
meta.textContent = opt.dataset.meta || '';
card.appendChild(meta);
if (opt.dataset.ctx === '1') {
const ctx = document.createElement('span');
ctx.className = 'set-mc-ctx';
ctx.textContent = '1M capable';
card.appendChild(ctx);
}
card.addEventListener('click', () => {
this._settingsModelBase = opt.value;
this._applyModelSelection();
});
grid.appendChild(card);
});
grid.dataset.built = '1';
},
/** Derive card + context-switch state from the select's stored value. */
_syncModelCards() {
const select = document.getElementById('appSettingsClaudeModel');
if (!select) return;
const value = select.value || '';
this._settingsModelBase = value.endsWith('[1m]') ? value.slice(0, -4) : value;
if (value.endsWith('[1m]')) {
const ctx = document.getElementById('appSettingsOpusContext1m');
if (ctx) ctx.checked = true;
}
this._applyModelSelection();
},
/** Compose card + context switch back into the select's value. */
_applyModelSelection() {
const select = document.getElementById('appSettingsClaudeModel');
const grid = document.getElementById('appSettingsModelCards');
if (!select || !grid) return;
const base = this._settingsModelBase || '';
let capable = false;
grid.querySelectorAll('.set-modelcard').forEach(card => {
const on = card.dataset.value === base;
card.classList.toggle('selected', on);
card.setAttribute('aria-checked', on ? 'true' : 'false');
if (on) capable = card.dataset.ctx === '1';
});
const ctxOn = !!document.getElementById('appSettingsOpusContext1m')?.checked;
select.value = base && capable && ctxOn ? `${base}[1m]` : base;
// A model with no 1M variant makes the switch inert; say so instead of
// leaving a toggle that looks like it does something.
const row = document.getElementById('appSettingsContextRow');
const desc = document.getElementById('appSettingsContextDesc');
const inert = !!base && !capable;
row?.classList.toggle('set-row-disabled', inert);
if (desc) {
desc.textContent = inert
? 'The selected model has no 1M variant.'
: base
? 'Available for Fable 5, Opus and Opus 4.6.'
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
}
},
_buildEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute('role', 'radio');
btn.dataset.value = opt.value;
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
btn.classList.toggle('selected', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
},
/**
* Live filter across every section. Everything stays mounted (that is the
* point of the single-document layout), so a search only hides units that do
* not match, then collapses groups and sections left with nothing visible.
*/
_filterSettings(query) {
const doc = document.getElementById('appSettingsDoc');
if (!doc) return;
const q = (query || '').trim().toLowerCase();
const UNIT = '.set-row, .set-chip, .set-modelgrid, .set-minigrid, .event-type-grid, #appSettingsShortcutsList';
const units = [...doc.querySelectorAll(UNIT)];
let anyVisible = false;
units.forEach(unit => {
if (!q) {
unit.classList.remove('set-hit-hidden');
return;
}
const hay = `${unit.dataset?.search || ''} ${unit.textContent || ''}`.toLowerCase();
const hit = hay.includes(q);
unit.classList.toggle('set-hit-hidden', !hit);
if (hit) anyVisible = true;
});
// A chip wrapper is only empty when every chip inside it is hidden.
doc.querySelectorAll('.set-chips').forEach(wrap => {
const hasVisible = [...wrap.querySelectorAll('.set-chip')].some(c => !c.classList.contains('set-hit-hidden'));
wrap.classList.toggle('set-hit-hidden', !!q && !hasVisible);
});
doc.querySelectorAll('.set-group').forEach(group => {
const hasVisible = [...group.querySelectorAll(UNIT)].some(u => !u.classList.contains('set-hit-hidden'));
group.classList.toggle('set-hit-hidden', !!q && !hasVisible);
// An Advanced group that matches must open, or the hit stays invisible.
if (q && hasVisible) group.classList.add('open');
});
doc.querySelectorAll('.set-section').forEach(section => {
const hasVisible = [...section.querySelectorAll('.set-group')].some(g => !g.classList.contains('set-hit-hidden'));
section.classList.toggle('set-hit-hidden', !!q && !hasVisible);
});
const empty = document.getElementById('appSettingsSearchEmpty');
if (empty) empty.hidden = !q || anyVisible;
if (!q) doc.querySelectorAll('.set-group-advanced').forEach(g => g.classList.remove('open'));
},
closeAppSettings() {
this._toggleSettingsJump(false);
document.getElementById('appSettingsModal').classList.remove('active');
// Deactivate focus trap and restore focus
+804 -7
View File
@@ -1316,11 +1316,31 @@ html[data-line-anim="packet"] .connection-line.line-enter {
.session-tab .tab-status.idle { background: var(--green); }
.session-tab .tab-status.busy {
position: relative;
background: var(--green);
animation: pulse 1.5s infinite;
/* Performance: GPU acceleration for animation */
will-change: opacity;
}
/* Orbiting ring on a WORKING tab: the same `tab-load-spin` circle the desktop
home rail (.home-sessions-dot--working) and the phone overview
(.mobile-overview-dot--working) draw, so "working" reads identically on every
surface. Pseudo-element on purpose — the skin block below sets
`box-shadow: none` on .tab-status.busy to keep tab dots halo-free, and it
outranks any plain class rule here; a ring drawn with a border is untouched by
that and keeps the tabs quiet while still showing motion. Absolutely
positioned, so it never widens the tab or shifts the label. */
.session-tab .tab-status.busy::after {
content: '';
position: absolute;
inset: -3px;
border: 1.5px solid color-mix(in srgb, var(--green) 25%, transparent);
border-top-color: var(--green);
border-radius: 50%;
animation: tab-load-spin 0.7s linear infinite;
pointer-events: none;
}
.session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
@@ -3666,6 +3686,13 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #95e6b3;
}
/* Past session (issue #261): activating the card resumes the conversation
rather than switching to a tab, so the badge says so. */
.search-badge-past {
background: rgba(245, 158, 11, 0.18);
color: #f0c073;
}
.search-result-name {
flex: 1;
min-width: 0;
@@ -3715,24 +3742,99 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.search-select {
max-width: 7rem;
}
/* Tablet/narrow: let the controls drop under the title instead of squeezing it. */
.history-header {
flex-wrap: wrap;
}
.history-controls {
width: 100%;
margin-left: 0;
}
.history-filter {
flex: 1;
width: auto;
}
}
/* Title row for the past-session list: label + count on the left, filter and
sort on the right (issue #260, 35 conversations in a 4-row box). */
.history-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.history-title {
font-size: 0.85rem;
color: var(--text-dim);
margin-bottom: 0.5rem;
font-weight: 500;
text-align: left;
}
.history-count {
font-size: 0.68rem;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.05);
border-radius: 999px;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
.history-controls {
display: flex;
align-items: center;
gap: 0.35rem;
margin-left: auto;
}
.history-filter {
width: 8.5rem;
box-sizing: border-box;
padding: 0.28rem 0.5rem;
font-size: 0.68rem;
color: var(--text);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
outline: none;
transition: border-color var(--transition-smooth), background var(--transition-smooth);
}
.history-filter:focus {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(255, 255, 255, 0.06);
}
.history-filter::placeholder {
color: var(--text-dim);
}
.history-sort {
max-width: 7.5rem;
}
.history-empty {
padding: 0.75rem 0.5rem;
font-size: 0.75rem;
color: var(--text-dim);
text-align: center;
}
/* Collapsed height fits the initial page of rows; expanding the LIST has to
expand the BOX too, or "Show more" just deepens a scroll well. */
.history-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 240px;
max-height: min(42vh, 360px);
overflow-y: auto;
}
.history-list.expanded {
max-height: min(64vh, 660px);
}
.history-item {
display: flex;
flex-direction: column;
@@ -3840,6 +3942,17 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: var(--green);
}
/* Worktree pill (#266). Tinted off the accent so it reads as metadata rather
than status — LIVE is the only badge that should look like state. */
.history-item-badge-worktree {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
max-width: 22ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-item-meta {
font-size: 0.7rem;
color: var(--text-muted);
@@ -5765,11 +5878,6 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
min-height: 0;
}
/* App Settings packs a 2-column toggle grid; give it room so right-column
switches aren't cramped against the edge (other modal-lg modals stay 540). */
#appSettingsModal .modal-content.modal-lg {
max-width: 600px;
}
/* Mobile Case Picker - Base Styles */
.mobile-case-picker-sheet {
@@ -14430,9 +14538,698 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
}
@media (prefers-reduced-motion: reduce) {
.session-tab .tab-status.busy::after,
.home-sessions-row,
.home-sessions-dot,
.home-sessions-dot::after {
animation: none !important;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
App Settings (#appSettingsModal)
A left rail that is a TABLE OF CONTENTS over ONE scrolling document, not a
tab switcher: every section stays mounted, the rail follows the scroll, and
the browser's own find-in-page still works across all of them.
Scoped under #appSettingsModal on purpose. `.modal-tabs`/`.modal-tab-btn`
are shared with #sessionOptionsModal and #createCaseModal, so this file
introduces its own `set-*` classes rather than restyling those.
═══════════════════════════════════════════════════════════════════════════ */
#appSettingsModal .modal-content.modal-lg {
max-width: min(960px, 96vw);
width: min(960px, 96vw);
height: min(700px, 88vh);
max-height: 88vh;
}
#appSettingsModal .set-shell-head {
flex-shrink: 0;
}
#appSettingsModal .set-head-actions {
display: flex;
flex-direction: row-reverse;
align-items: center;
gap: 6px;
margin-left: auto;
}
/* The phone layout drops the bottom action bar and saves from the header
instead; on desktop the footer owns Save, so this stays hidden. */
#appSettingsModal .set-head-save {
display: none;
font: inherit;
font-size: 0.82rem;
font-weight: 620;
letter-spacing: -0.01em;
padding: 8px 15px;
border: 0;
border-radius: 10px;
cursor: pointer;
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
color: var(--accent-ink);
}
#appSettingsModal .set-body {
display: grid;
grid-template-columns: 212px minmax(0, 1fr);
flex: 1;
min-height: 0;
}
#appSettingsModal .set-foot {
margin-top: 0;
flex-shrink: 0;
}
/* ── rail ─────────────────────────────────────────────────────────────── */
#appSettingsModal .set-rail {
display: flex;
flex-direction: column;
padding: 12px 10px;
border-right: 1px solid var(--border);
background: rgba(0, 0, 0, 0.16);
overflow-y: auto;
min-height: 0;
}
#appSettingsModal .set-search {
position: relative;
display: flex;
align-items: center;
margin-bottom: 10px;
flex-shrink: 0;
}
#appSettingsModal .set-search svg {
position: absolute;
left: 9px;
color: var(--text-muted);
pointer-events: none;
}
#appSettingsModal .set-search input {
width: 100%;
box-sizing: border-box;
padding: 7px 9px 7px 28px;
font: inherit;
font-size: 0.75rem;
color: var(--text);
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border);
border-radius: 8px;
outline: none;
-webkit-appearance: none;
appearance: none;
}
#appSettingsModal .set-search input:focus {
border-color: var(--accent);
}
#appSettingsModal .set-rail-items {
display: flex;
flex-direction: column;
gap: 1px;
}
#appSettingsModal .set-rail-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.77rem;
cursor: pointer;
text-align: left;
position: relative;
transition: color 0.15s, background 0.15s;
}
#appSettingsModal .set-rail-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
#appSettingsModal .set-rail-item:hover {
background: var(--control-bg);
color: var(--text);
}
#appSettingsModal .set-rail-item.active {
color: var(--accent);
font-weight: 580;
}
#appSettingsModal .set-rail-item.active svg {
color: var(--accent);
}
#appSettingsModal .set-rail-item.active::before {
content: '';
position: absolute;
left: 2px;
top: 6px;
bottom: 6px;
width: 2px;
border-radius: 2px;
background: var(--accent);
}
#appSettingsModal .set-rail-foot {
margin: auto 0 0;
padding: 10px 10px 2px;
font-size: 0.62rem;
line-height: 1.5;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
/* ── document ─────────────────────────────────────────────────────────── */
#appSettingsModal .set-doc {
overflow-y: auto;
padding: 0 22px 40px;
min-width: 0;
min-height: 0;
position: relative;
}
#appSettingsModal .set-section {
padding-top: 20px;
}
#appSettingsModal .set-section + .set-section {
border-top: 1px solid var(--border);
margin-top: 26px;
}
#appSettingsModal .set-section-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
#appSettingsModal .set-section-head svg {
color: var(--accent);
flex-shrink: 0;
padding: 5px;
border-radius: 8px;
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.24);
box-sizing: content-box;
}
#appSettingsModal .set-section-head h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 640;
letter-spacing: -0.015em;
color: var(--text);
}
#appSettingsModal .set-section-blurb {
margin: 0 0 14px 36px;
font-size: 0.69rem;
color: var(--text-muted);
}
#appSettingsModal .set-empty {
margin: 24px 0 0;
font-size: 0.78rem;
color: var(--text-muted);
text-align: center;
}
/* ── groups ───────────────────────────────────────────────────────────── */
#appSettingsModal .set-group + .set-group {
margin-top: 18px;
}
#appSettingsModal .set-group-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
#appSettingsModal .set-group-head h4 {
margin: 0;
font-size: 0.63rem;
font-weight: 640;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
#appSettingsModal .set-group-head::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
order: 9;
}
#appSettingsModal .set-group-head-toggle {
cursor: pointer;
}
#appSettingsModal .set-adv {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.62rem;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 20px;
padding: 2px 8px;
}
#appSettingsModal .set-adv svg {
transition: transform 0.18s;
}
#appSettingsModal .set-group-advanced.open .set-adv svg {
transform: rotate(180deg);
}
#appSettingsModal .set-group-advanced:not(.open) .set-group-body,
#appSettingsModal .set-group-advanced:not(.open) .set-group-hint {
display: none;
}
#appSettingsModal .set-group-hint {
margin: -2px 0 9px;
font-size: 0.68rem;
line-height: 1.45;
color: var(--text-muted);
}
#appSettingsModal .set-group-body {
display: flex;
flex-direction: column;
gap: 5px;
}
/* ── rows ─────────────────────────────────────────────────────────────── */
#appSettingsModal .set-row {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 12px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.028);
border: 1px solid rgba(255, 255, 255, 0.055);
transition: background 0.15s, border-color 0.15s;
}
#appSettingsModal .set-row:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.09);
}
#appSettingsModal .set-row-block {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
#appSettingsModal .set-row-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
#appSettingsModal .set-row-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 0.79rem;
color: var(--text);
letter-spacing: -0.005em;
}
#appSettingsModal .set-row-desc {
font-size: 0.66rem;
line-height: 1.45;
color: var(--text-muted);
max-width: 62ch;
}
#appSettingsModal .set-row-actions {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
#appSettingsModal .set-scope,
#appSettingsModal .set-tag {
font-size: 0.52rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 4px;
white-space: nowrap;
}
#appSettingsModal .set-scope {
color: var(--text-muted);
border: 1px solid var(--border);
opacity: 0.8;
}
#appSettingsModal .set-tag {
color: var(--accent);
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.25);
}
#appSettingsModal .set-tag-beta {
color: var(--yellow);
background: rgba(240, 194, 90, 0.12);
border-color: rgba(240, 194, 90, 0.28);
}
/* ── controls ─────────────────────────────────────────────────────────── */
#appSettingsModal .set-select,
#appSettingsModal .set-input,
#appSettingsModal .set-num {
font: inherit;
font-size: 0.74rem;
color: var(--text);
background: rgba(0, 0, 0, 0.24);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px 8px;
outline: none;
flex-shrink: 0;
}
#appSettingsModal .set-select:focus,
#appSettingsModal .set-input:focus,
#appSettingsModal .set-num:focus {
border-color: var(--accent);
}
#appSettingsModal .set-select {
min-width: 190px;
max-width: 260px;
}
#appSettingsModal .set-input {
width: 220px;
}
#appSettingsModal .set-num {
width: 62px;
text-align: right;
}
#appSettingsModal .set-unit {
font-size: 0.65rem;
color: var(--text-muted);
}
#appSettingsModal .set-static {
font-size: 0.74rem;
color: var(--text-dim);
}
#appSettingsModal .mono {
font-family: var(--font-mono);
}
#appSettingsModal .set-copy {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
text-decoration: underline;
cursor: pointer;
word-break: break-all;
}
/* The cards and the segmented control are the visible UI; the underlying
<select> stays in the DOM as the single source of truth that
openAppSettings()/saveAppSettings() read and write by id. */
#appSettingsModal .set-field-hidden {
display: none !important;
}
#appSettingsModal .set-note,
#appSettingsModal .set-notes,
#appSettingsModal .set-progress {
font-size: 0.72rem;
line-height: 1.5;
color: var(--text-secondary);
padding: 8px 10px;
border-radius: 8px;
}
#appSettingsModal .set-notes {
max-height: 160px;
overflow: auto;
font-size: 0.7rem;
white-space: pre-wrap;
word-break: break-word;
background: rgba(127, 127, 127, 0.08);
border: 1px solid var(--border);
}
#appSettingsModal .set-progress {
border: 1px solid var(--border);
}
/* ── chips (header buttons, panels, features, levels) ─────────────────── */
#appSettingsModal .set-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
#appSettingsModal .set-chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--control-border);
background: rgba(255, 255, 255, 0.028);
color: var(--text-dim);
cursor: pointer;
user-select: none;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
#appSettingsModal .set-chip input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
#appSettingsModal .set-chip::before {
content: '\2713';
font-size: 0.8em;
line-height: 1;
width: 0;
overflow: hidden;
opacity: 0;
transition: width 0.15s, opacity 0.15s;
}
#appSettingsModal .set-chip:hover {
border-color: var(--control-border-hover);
color: var(--text);
}
/* `.is-on` is written by settings-ui.js on load and on change; `:has()` keeps
the state correct for any chip toggled before that listener runs. */
#appSettingsModal .set-chip.is-on,
#appSettingsModal .set-chip:has(input:checked) {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(var(--accent-rgb), 0.12);
}
#appSettingsModal .set-chip.is-on::before,
#appSettingsModal .set-chip:has(input:checked)::before {
width: 0.85em;
opacity: 1;
}
#appSettingsModal .set-chip:focus-within {
outline: 2px solid rgba(var(--accent-rgb), 0.5);
outline-offset: 1px;
}
/* ── model cards ──────────────────────────────────────────────────────── */
#appSettingsModal .set-modelgrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
#appSettingsModal .set-modelcard {
display: flex;
flex-direction: column;
gap: 3px;
text-align: left;
font: inherit;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid var(--control-border);
background: rgba(255, 255, 255, 0.028);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
#appSettingsModal .set-modelcard:hover {
border-color: var(--control-border-hover);
background: rgba(255, 255, 255, 0.05);
}
#appSettingsModal .set-modelcard.selected {
border-color: rgba(var(--accent-rgb), 0.55);
background: rgba(var(--accent-rgb), 0.11);
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.2);
}
#appSettingsModal .set-mc-top {
display: flex;
align-items: center;
gap: 6px;
}
#appSettingsModal .set-mc-name {
font-size: 0.78rem;
color: var(--text);
font-weight: 560;
}
#appSettingsModal .set-mc-dot {
margin-left: auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.8);
opacity: 0;
}
#appSettingsModal .set-modelcard.selected .set-mc-dot {
opacity: 1;
}
#appSettingsModal .set-mc-meta {
font-size: 0.64rem;
color: var(--text-muted);
}
#appSettingsModal .set-mc-ctx {
margin-top: 2px;
align-self: flex-start;
font-size: 0.53rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 4px;
}
#appSettingsModal .set-row-disabled {
opacity: 0.45;
}
/* ── segmented control (thinking effort) ──────────────────────────────── */
#appSettingsModal .set-segment {
display: flex;
gap: 3px;
padding: 3px;
background: rgba(0, 0, 0, 0.26);
border: 1px solid var(--border);
border-radius: 9px;
}
#appSettingsModal .set-segment button {
flex: 1;
font: inherit;
font-size: 0.68rem;
padding: 6px 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
#appSettingsModal .set-segment button:hover {
color: var(--text);
background: var(--control-bg);
}
#appSettingsModal .set-segment button.selected {
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
color: var(--accent-ink);
font-weight: 620;
}
/* ── task-routing 2x2 ─────────────────────────────────────────────────── */
#appSettingsModal .set-minigrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
}
#appSettingsModal .set-mini {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.028);
border: 1px solid rgba(255, 255, 255, 0.055);
}
#appSettingsModal .set-mini-label {
font-size: 0.74rem;
color: var(--text-dim);
}
#appSettingsModal .set-mini .set-select {
min-width: 0;
width: 128px;
}
/* Search hides non-matching units; everything stays mounted underneath. */
#appSettingsModal .set-hit-hidden {
display: none !important;
}
/* ── phone jump control: desktop hides it entirely ────────────────────── */
#appSettingsModal .set-jump,
#appSettingsModal .set-jump-menu,
#appSettingsModal .set-jump-veil {
display: none;
}
/* ── injected sections (admin Users panel) ────────────────────────────── */
#appSettingsModal .set-section .form-hint {
display: block;
margin: 0 0 10px;
}
+226 -31
View File
@@ -1517,6 +1517,24 @@ Object.assign(CodemanApp.prototype, {
* - workingDir under a case dir → "#caseName/subdir"
* - Otherwise → basename (e.g. "Claudeman")
*/
/**
* Badge text for a session's git worktree, or '' when it isn't on one.
* `⑂ <name> · <branch>`, either half alone if that's all we know.
* Branch is truncated: the badge row is a single nowrap line.
*/
_worktreeLabel(s) {
// Worktree name is REQUIRED. gitBranch alone is not worktree information —
// every ordinary repo session has one, and badging all of them with `⑂ master`
// is noise that buries the rows this badge exists to distinguish.
const name = s && s.worktreeName;
if (!name) return '';
let branch = s.gitBranch || '';
// A worktree's branch often just restates its name; don't print it twice.
if (branch === name || branch === `worktree-${name}`) branch = '';
if (branch.length > 24) branch = branch.slice(0, 23) + '\u2026';
return '⑂ ' + [name, branch].filter(Boolean).join(' · ');
},
_resolveCaseLabel(workingDir, cases) {
if (!workingDir) return '';
let best = null;
@@ -1628,7 +1646,7 @@ Object.assign(CodemanApp.prototype, {
pin.title = 'Pinned';
titleSpan.appendChild(pin);
}
titleSpan.appendChild(document.createTextNode(s.name || s.firstPrompt || shortDir));
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
// Badge row: mode (claude/codex/opencode/gemini/antigravity/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
@@ -1639,6 +1657,18 @@ Object.assign(CodemanApp.prototype, {
modeBadge.textContent = s.mode;
badgeRow.appendChild(modeBadge);
}
// Worktree pill (#266): distinguishes sessions from different worktrees of the
// same repo, which are otherwise identical in this list. Name AND branch when
// both are known; a hand-made `git worktree add` yields no recoverable name,
// so it degrades to branch-only rather than guessing one.
const wtLabel = this._worktreeLabel(s);
if (wtLabel) {
const wtBadge = document.createElement('span');
wtBadge.className = 'history-item-badge history-item-badge-worktree';
wtBadge.textContent = wtLabel;
wtBadge.title = s.worktreeRepo ? `worktree of ${s.worktreeRepo}` : wtLabel;
badgeRow.appendChild(wtBadge);
}
if (isLive) {
const liveBadge = document.createElement('span');
liveBadge.className = 'history-item-badge history-item-badge-live';
@@ -1954,7 +1984,18 @@ Object.assign(CodemanApp.prototype, {
},
/** Number of history items shown before "Show More" */
_HISTORY_INITIAL_COUNT: 4,
_HISTORY_INITIAL_COUNT: 10,
/**
* How many past sessions the home screen loads (also the filter/sort corpus).
* 200, not the old 60, so the filter can reach a real backlog, an install with
* 35+ conversations would otherwise hit the ceiling before the filter is useful
* (raised in @jordan8037310's #263; the endpoint clamps at 500).
*/
_HISTORY_FETCH_LIMIT: 200,
/** localStorage key for the per-device sort choice (#263). */
_HISTORY_SORT_KEY: 'codeman:historySort',
async loadHistorySessions() {
const container = document.getElementById('historySessions');
@@ -1968,7 +2009,7 @@ Object.assign(CodemanApp.prototype, {
? Promise.resolve(this.cases)
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
const [allSessions, cases] = await Promise.all([
this._fetchUnifiedSessions(60),
this._fetchUnifiedSessions(this._HISTORY_FETCH_LIMIT),
casesPromise,
]);
if (allSessions.length === 0) {
@@ -1976,27 +2017,14 @@ Object.assign(CodemanApp.prototype, {
return;
}
list.replaceChildren();
const initialCount = this._HISTORY_INITIAL_COUNT;
// Render initial items
for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) {
list.appendChild(this._buildHistoryItem(allSessions[i], cases));
}
// Add "Show More" button if there are more items
if (allSessions.length > initialCount) {
const moreBtn = document.createElement('button');
moreBtn.className = 'history-show-more';
moreBtn.textContent = `Show ${allSessions.length - initialCount} more`;
moreBtn.addEventListener('click', () => {
for (let i = initialCount; i < allSessions.length; i++) {
list.insertBefore(this._buildHistoryItem(allSessions[i], cases), moreBtn);
}
moreBtn.remove();
});
list.appendChild(moreBtn);
}
// Keep the corpus around: filtering and sorting (issue #260) work on this
// array, so a re-render costs no request. Expansion survives the periodic
// refresh in panels-ui.js, collapsing the list under the user's cursor
// every few seconds would be worse than the original 4-item cap.
this._historyAll = allSessions;
this._historyCases = cases;
this._wireHistoryControls();
this._renderHistoryList();
container.style.display = '';
} catch (err) {
@@ -2005,6 +2033,161 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Wire the filter box and sort select once; both re-render from the cached
* corpus. The sort choice is restored from (and saved to) localStorage, it is
* a per-device display preference, so it stays out of the synced settings
* schema, same as `codeman:skin`.
*/
_wireHistoryControls() {
if (this._historyControlsWired) return;
const filter = document.getElementById('historyFilter');
const sort = document.getElementById('historySort');
if (!filter && !sort) return;
this._historyControlsWired = true;
if (sort) {
try {
const saved = localStorage.getItem(this._HISTORY_SORT_KEY);
if (saved && Array.from(sort.options).some((o) => o.value === saved)) sort.value = saved;
} catch {
/* private mode, the order just won't persist */
}
}
if (filter) {
filter.addEventListener('input', () => this._renderHistoryList());
filter.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape' && filter.value) {
// Swallow it: Escape at the welcome screen otherwise closes overlays.
ev.stopPropagation();
filter.value = '';
this._renderHistoryList();
}
});
}
if (sort) {
sort.addEventListener('change', () => {
try {
localStorage.setItem(this._HISTORY_SORT_KEY, sort.value);
} catch {
/* private mode, the order just won't persist */
}
this._renderHistoryList();
});
}
},
/** True when a past-session row matches the filter text (name, folder, case, prompt). */
_historyRowMatches(s, needle, cases) {
const fields = [
s.name,
s.workingDir,
this._resolveCaseLabel(s.workingDir, cases),
s.firstPrompt,
s.lastPrompt,
s.sessionId,
];
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(needle));
},
/**
* The text a history row shows as its title. Most transcript-backed rows have
* no session name at all, so this falls through to the first prompt and then
* to the path, and the A–Z sort keys off the SAME string, or "sort by name"
* would silently do nothing for exactly the rows the list is mostly made of.
*/
_historyRowLabel(s, fallback) {
return s.name || s.firstPrompt || fallback || '';
},
/**
* Sort past-session rows. 'recent' keeps the backend order (newest first);
* the alphabetical modes sort by the visible title or by folder basename.
* Pinned rows stay on top in every mode, pinning is an explicit override and
* a sort that buried it would read as the pin having been lost.
*/
_sortHistoryRows(rows, mode) {
const label = (s) => this._historyRowLabel(s, this._shortenHomePath(s.workingDir)).toLowerCase();
const folder = (s) => ((s.workingDir || '').split('/').pop() || '').toLowerCase();
const key = mode === 'name' ? label : folder;
// numeric collation so w2-… sorts before w10-…, and base sensitivity so case
// does not split a project's rows apart (from @jordan8037310's #263).
const sorted =
mode === 'recent'
? rows.slice()
: rows
.slice()
.sort((a, b) => key(a).localeCompare(key(b), undefined, { sensitivity: 'base', numeric: true }));
const pinned = sorted.filter((s) => s.pinned);
return pinned.length === 0 ? sorted : pinned.concat(sorted.filter((s) => !s.pinned));
},
/**
* Render the "Resume Conversation" list from the cached corpus, applying the
* current filter and sort. Collapsed by default to _HISTORY_INITIAL_COUNT;
* "Show more" expands the list AND the box (the CSS cap is class-driven, since
* a fixed 240px box made expansion pointless, issue #260).
*/
_renderHistoryList() {
const list = document.getElementById('historyList');
if (!list) return;
const all = this._historyAll || [];
const cases = this._historyCases || [];
const countEl = document.getElementById('historyCount');
const needle = (document.getElementById('historyFilter')?.value || '').trim().toLowerCase();
const mode = document.getElementById('historySort')?.value || 'recent';
const matched = needle ? all.filter((s) => this._historyRowMatches(s, needle, cases)) : all;
const rows = this._sortHistoryRows(matched, mode);
// Filtering is itself an expansion request: hiding matches behind "Show more"
// would defeat the point of typing a filter.
const expanded = !!this._historyExpanded || needle.length > 0;
const visible = expanded ? rows : rows.slice(0, this._HISTORY_INITIAL_COUNT);
list.replaceChildren();
list.classList.toggle('expanded', expanded);
if (rows.length === 0) {
const empty = document.createElement('div');
empty.className = 'history-empty';
empty.textContent = `No conversations match "${needle}"`;
list.appendChild(empty);
}
for (const s of visible) list.appendChild(this._buildHistoryItem(s, cases));
const hidden = rows.length - visible.length;
if (hidden > 0) {
const moreBtn = document.createElement('button');
moreBtn.className = 'history-show-more';
moreBtn.textContent = `Show ${hidden} more`;
moreBtn.addEventListener('click', () => {
this._historyExpanded = true;
this._renderHistoryList();
});
list.appendChild(moreBtn);
} else if (expanded && !needle && rows.length > this._HISTORY_INITIAL_COUNT) {
const lessBtn = document.createElement('button');
lessBtn.className = 'history-show-more';
lessBtn.textContent = 'Show less';
lessBtn.addEventListener('click', () => {
this._historyExpanded = false;
this._renderHistoryList();
list.scrollTop = 0;
});
list.appendChild(lessBtn);
}
if (countEl) {
countEl.textContent = needle
? `${rows.length} of ${all.length}`
: rows.length > visible.length
? `${visible.length} of ${rows.length}`
: String(rows.length);
}
},
/** Page size for the folder history modal */
_FOLDER_HISTORY_PAGE_SIZE: 20,
@@ -3832,13 +4015,16 @@ Object.assign(CodemanApp.prototype, {
/** Render the grouped result cards (or empty/loading states). */
_renderSearch(data) {
const results = document.getElementById('searchResults');
const historyTitle = document.getElementById('historyTitle');
// The header carries the title plus the filter/sort controls (issue #260),
// hide the whole row, not just the title, or the controls float above the
// search results and act on a list that is not on screen.
const historyHeader = document.getElementById('historyHeader') || document.getElementById('historyTitle');
const historyList = document.getElementById('historyList');
if (!results) return;
const searching = !!data;
// Hide the plain "Resume Conversation" history list while a search is active.
if (historyTitle) historyTitle.style.display = searching ? 'none' : '';
if (historyHeader) historyHeader.style.display = searching ? 'none' : '';
if (historyList) historyList.style.display = searching ? 'none' : '';
results.innerHTML = '';
@@ -3907,9 +4093,11 @@ Object.assign(CodemanApp.prototype, {
const topRow = document.createElement('div');
topRow.className = 'search-result-top';
// A past session resumes rather than switches tabs, so it says so on the badge.
const isPast = r.jumpTo && r.jumpTo.kind === 'resume-session';
const badge = document.createElement('span');
badge.className = 'search-result-badge search-badge-' + r.type;
badge.textContent = (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
badge.className = 'search-result-badge search-badge-' + r.type + (isPast ? ' search-badge-past' : '');
badge.textContent = isPast ? 'Resume' : (window.CodemanSearch.SOURCE_LABELS[r.type] || r.type).replace(/s$/, '');
const name = document.createElement('span');
name.className = 'search-result-name';
@@ -3941,13 +4129,20 @@ Object.assign(CodemanApp.prototype, {
/**
* Navigate to a search result by jumpTo.kind, reusing the existing app methods:
* session → selectSession(sessionId) (open/switch to the session)
* run-summary → openRunSummary(sessionId) (session options → summary tab)
* file-preview→ openFilePreview(path, sessionId, attachmentId)
* session → selectSession(sessionId) (open/switch to the session)
* resume-session→ resumeHistorySession(...) (past session, no tab to switch to)
* run-summary → openRunSummary(sessionId) (session options → summary tab)
* file-preview → openFilePreview(path, sessionId, attachmentId)
*/
_jumpToSearchResult(r) {
const jt = r && r.jumpTo;
if (!jt) return;
// A past session has to be replayed, not switched to. Do it BEFORE hiding the
// welcome overlay: resumeHistorySession() owns that transition itself.
if (jt.kind === 'resume-session') {
this.resumeHistorySession(jt.claudeSessionId || jt.sessionId, jt.workingDir || '', r.sessionName);
return;
}
// Leaving the welcome overlay so the target surface is visible.
if (typeof this.hideWelcome === 'function') this.hideWelcome();
+34 -1
View File
@@ -3,7 +3,9 @@
*
* Registers `GET /api/search?q=&types=&limit=` — a bounded, in-memory search
* across three v1 sources, returned in the standard ApiResponse envelope:
* 1. sessions/cases — name, working directory, session id
* 1. sessions/cases, name, working directory, session id, for LIVE sessions
* plus the past-session snapshot in `session-history-index.ts` (issue #261:
* the live map alone made every closed session unfindable by folder name)
* 2. run-summary events — event title/details (from the live run-summary trackers)
* 3. file paths — per-session attachment history (workspace-relative paths only)
*
@@ -34,6 +36,7 @@ import {
} from '../../search-service.js';
import type { SearchSourceType } from '../../types/search.js';
import type { SessionPort, InfraPort } from '../ports/index.js';
import { ensureHistorySessionIndexFresh, getHistorySessionIndex } from '../session-history-index.js';
/**
* Per-source harvest caps. These bound how much in-memory data we hand to the
@@ -61,11 +64,17 @@ interface SessionLike {
/**
* Harvest the three source arrays from the live in-memory stores. Reads only
* bounded, already-loaded data — no disk I/O, no terminal buffers.
*
* Past sessions come from the `session-history-index` snapshot, which is built
* outside the request path for exactly that reason. Live rows are harvested
* first and win the dedupe, so a session that is both live and in the snapshot
* keeps its live jump-to (switch to the tab) instead of a resume.
*/
function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string) => boolean): SearchSources {
const sessions: SessionSearchInput[] = [];
const events: EventSearchInput[] = [];
const files: FileSearchInput[] = [];
const seenSessionIds = new Set<string>();
for (const raw of ctx.sessions.values()) {
const s = raw as unknown as SessionLike & { owner?: string };
@@ -73,6 +82,7 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string)
const sessionName = s.name ?? '';
const timestamp = s.lastActivityAt ?? s.createdAt ?? 0;
seenSessionIds.add(s.id);
sessions.push({
sessionId: s.id,
sessionName,
@@ -95,6 +105,24 @@ function harvestSources(ctx: SessionPort & InfraPort, canSee?: (owner?: string)
}
}
// Past sessions: the out-of-band snapshot of the unified list. Unscoped on
// disk, so every row goes through the same ownership check as a live one,
// host-wide transcript rows carry no owner and are therefore admin-only in
// multi-user mode, matching GET /api/sessions/unified.
for (const item of getHistorySessionIndex().items) {
if (seenSessionIds.has(item.sessionId)) continue;
if (canSee && !canSee(item.owner)) continue;
seenSessionIds.add(item.sessionId);
sessions.push({
sessionId: item.sessionId,
sessionName: item.name,
workingDir: item.workingDir,
timestamp: item.timestamp,
history: true,
claudeSessionId: item.claudeSessionId,
});
}
// Events: from the live run-summary trackers, keyed by session id.
for (const [sessionId, tracker] of ctx.runSummaryTrackers) {
const session = ctx.sessions.get(sessionId) as unknown as (SessionLike & { owner?: string }) | undefined;
@@ -134,6 +162,11 @@ export function registerSearchRoutes(app: FastifyInstance, ctx: SessionPort & In
)
: null;
// Fire-and-forget: a stale past-session snapshot is rebuilt in the
// background. This query still answers from whatever is already in memory,
// which is what keeps the request path free of disk I/O.
ensureHistorySessionIndexFresh();
const sources = harvestSources(ctx, canSee);
// Apply the optional source-type filter before searching so excluded
+182 -11
View File
@@ -94,7 +94,13 @@ import {
type LifecycleInput,
type HistoryInput,
type MuxStatInput,
type UnifiedSessionItem,
} from '../../services/unified-session-service.js';
import {
buildHistorySessionIndexItems,
setHistoryIndexRefresher,
setHistorySessionIndex,
} from '../session-history-index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { RunSummaryTracker } from '../../run-summary.js';
@@ -3116,6 +3122,91 @@ export function registerSessionRoutes(
return sawNonCli;
}
/** Git/worktree facts recovered from a transcript. Every field is optional —
* "unknown" must stay distinguishable from "not a worktree" (#265/#266). */
type TranscriptGitInfo = {
/** The literal `cwd` Claude Code stamped on its own records. */
cwd?: string;
gitBranch?: string;
worktreeName?: string;
/** Main repo root the worktree belongs to. */
worktreeRepo?: string;
};
/** `<repo>/.claude/worktrees/<name>` — the layout Claude Code's own worktree feature creates. */
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
/**
* Recover cwd / branch / worktree from a transcript chunk.
*
* Claude Code stamps `"cwd"` and `"gitBranch"` on every user/assistant record,
* and writes a dedicated `worktree-state` record when the session was started
* through its own worktree feature. This reads buffers `scanProjectDir` has
* ALREADY loaded, so it costs no extra file I/O.
*
* Why this matters beyond a label: `decodeProjectKey()` reconstructs a path by
* stat-walking the filesystem and falls back to `$HOME` when nothing resolves.
* A deleted worktree is the normal end of a worktree's life, so every past
* worktree session used to collapse onto `$HOME` (#265). The transcript value
* is the literal cwd — non-lossy, and it survives the directory being removed.
*
* cwd is taken from the FIRST record that carries it (a session's cwd does not
* move); gitBranch from the LAST (a branch genuinely changes mid-session, and
* the newest value in the scanned chunk is the closest to current).
*/
function extractTranscriptGitInfo(text: string): TranscriptGitInfo {
const info: TranscriptGitInfo = {};
let start = 0;
while (start < text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.slice(start) : text.slice(start, end);
start = end === -1 ? text.length : end + 1;
// Highest-confidence source: Claude's own worktree record. Names the
// worktree explicitly, so it beats anything inferred from the path.
if (line.includes('"worktree-state"')) {
try {
const rec = JSON.parse(line) as {
worktreeSession?: { worktreeName?: unknown; worktreePath?: unknown; originalCwd?: unknown };
};
const ws = rec.worktreeSession;
if (ws) {
if (typeof ws.worktreeName === 'string') info.worktreeName ||= ws.worktreeName;
if (typeof ws.originalCwd === 'string') info.worktreeRepo ||= ws.originalCwd;
if (typeof ws.worktreePath === 'string') info.cwd ||= ws.worktreePath;
}
} catch {
// Malformed/truncated line — skip
}
continue;
}
if (!line.includes('"cwd"') && !line.includes('"gitBranch"')) continue;
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
try {
const rec = JSON.parse(line) as { cwd?: unknown; gitBranch?: unknown };
if (!info.cwd && typeof rec.cwd === 'string' && rec.cwd) info.cwd = rec.cwd;
// Last one wins — closest to the session's current branch.
if (typeof rec.gitBranch === 'string' && rec.gitBranch) info.gitBranch = rec.gitBranch;
} catch {
// Malformed/truncated line — skip
}
}
// No explicit worktree record: infer from Claude's own worktree path layout.
// A worktree created by hand (`git worktree add` anywhere) has no recoverable
// NAME here — it still gets a branch, and the badge degrades to branch-only
// rather than guessing.
if (!info.worktreeName && info.cwd) {
const m = CLAUDE_WORKTREE_PATH.exec(info.cwd);
if (m) {
info.worktreeName = m[2];
info.worktreeRepo ||= m[1];
}
}
return info;
}
/**
* Extract the text of the LAST user message from a JSONL transcript chunk
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
@@ -3367,6 +3458,11 @@ export function registerSessionRoutes(
lastModified: string;
firstPrompt?: string;
lastPrompt?: string;
/** True when workingDir came from the transcript rather than decodeProjectKey's guess. */
workingDirExact?: boolean;
gitBranch?: string;
worktreeName?: string;
worktreeRepo?: string;
};
// Scan a single project directory and return all valid history sessions in it.
@@ -3473,14 +3569,34 @@ export function registerSessionRoutes(
headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue;
// Git/worktree facts from the buffers already read above — no extra I/O.
// head first (cwd is stamped near the top; median offset ~1KB), tail as the
// fallback for transcripts whose head read failed or came up empty.
const headGit = head ? extractTranscriptGitInfo(head) : {};
const tailGit = tail ? extractTranscriptGitInfo(tail) : {};
const git: TranscriptGitInfo = {
cwd: headGit.cwd ?? tailGit.cwd,
// Last-wins within a chunk; across chunks the tail is the newer one.
gitBranch: tailGit.gitBranch ?? headGit.gitBranch,
worktreeName: headGit.worktreeName ?? tailGit.worktreeName,
worktreeRepo: headGit.worktreeRepo ?? tailGit.worktreeRepo,
};
out.push({
sessionId,
workingDir,
// The transcript's literal cwd beats decodeProjectKey's stat-walked guess,
// which silently collapses to $HOME once the directory is gone (#265).
// Absent cwd falls back to the old behaviour rather than inventing a path.
workingDir: git.cwd ?? workingDir,
workingDirExact: git.cwd !== undefined,
projectKey: projDir,
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
lastPrompt,
gitBranch: git.gitBranch,
worktreeName: git.worktreeName,
worktreeRepo: git.worktreeRepo,
});
}
return out;
@@ -3539,16 +3655,20 @@ export function registerSessionRoutes(
return { sessions: results.slice(0, 50) };
});
// Unified, read-only session list: merges live + persisted + lifecycle +
// transcript history + mux stats into one de-duplicated, searchable list
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
app.get('/api/sessions/unified', async (req) => {
const query = req.query as { q?: string; offset?: string; limit?: string };
if (ctx.testMode) {
return { sessions: [], total: 0 };
}
/**
* Gather the four read-only views the unified list is merged from, plus mux
* stats. This is the expensive half (the lifecycle log and a scan of every
* Claude transcript), factored out of the route handler because the
* past-session search index rebuilds itself from the very same inputs, off
* the request path, see session-history-index.ts.
*/
async function gatherUnifiedInputs(): Promise<{
live: LiveSessionInput[];
persisted: PersistedSessionInput[];
lifecycle: LifecycleInput[];
history: HistoryInput[];
mux: MuxStatInput[];
}> {
// Live (in-memory) sessions.
const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => {
const st = s.toState();
@@ -3618,6 +3738,9 @@ export function registerSessionRoutes(
firstPrompt: h.firstPrompt,
lastPrompt: h.lastPrompt,
projectKey: h.projectKey,
gitBranch: h.gitBranch,
worktreeName: h.worktreeName,
worktreeRepo: h.worktreeRepo,
});
}
}
@@ -3649,14 +3772,54 @@ export function registerSessionRoutes(
// Mux stats are optional.
}
return { live, persisted, lifecycle, history, mux };
}
/**
* Publish a merged unified list as the past-session search index (issue #261).
* The snapshot is stored UNSCOPED with a per-row owner, so it must only ever be
* built from an unscoped merge, `harvestSources()` in search-routes re-applies
* the ownership check on read.
*/
function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void {
const ownerById = new Map<string, string | undefined>();
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
for (const p of Object.values(stored)) ownerById.set(p.id, p.owner);
// Live wins: a session's owner on disk can lag the running one.
for (const s of ctx.sessions.values()) ownerById.set(s.id, s.owner);
const liveIds = new Set(ctx.sessions.keys());
setHistorySessionIndex(buildHistorySessionIndexItems(merged, ownerById, liveIds));
}
// Rebuild hook for the search route: it kicks this (fire-and-forget) when the
// snapshot goes stale, so a search never pays for the scan itself.
setHistoryIndexRefresher(async () => {
if (ctx.testMode) return;
publishHistorySessionIndex(mergeUnifiedSessions(await gatherUnifiedInputs()));
});
// Unified, read-only session list: merges live + persisted + lifecycle +
// transcript history + mux stats into one de-duplicated, searchable list
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
app.get('/api/sessions/unified', async (req) => {
const query = req.query as { q?: string; offset?: string; limit?: string };
if (ctx.testMode) {
return { sessions: [], total: 0 };
}
const { live, persisted, lifecycle, history, mux } = await gatherUnifiedInputs();
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
// history (not tied to an owned session) is admin-only.
let sLive = live;
let sPersisted = persisted;
let sLifecycle = lifecycle;
let sHistory = history;
let scoped = false;
const uUser = getAuthUser(req);
if (isMultiUserMode() && uUser.role !== 'admin') {
scoped = true;
const ownedLive = new Set(
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
);
@@ -3680,6 +3843,14 @@ export function registerSessionRoutes(
history: sHistory,
mux,
});
// Refresh the search index off the back of this request, the home screen
// fetches this endpoint whenever it opens, which is the same screen the
// search box lives on, so the snapshot is warm before anyone types. A scoped
// merge is a per-user subset and would corrupt the shared snapshot, so that
// path re-merges unscoped instead (multi-user is opt-in and rarely hit).
publishHistorySessionIndex(scoped ? mergeUnifiedSessions({ live, persisted, lifecycle, history, mux }) : merged);
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
return filterAndPaginate(merged, {
+166
View File
@@ -0,0 +1,166 @@
/**
* @fileoverview Bounded in-memory index of PAST sessions, harvested by `GET /api/search`.
*
* `GET /api/search` used to build its session corpus from the live in-memory
* session map alone, so a folder sitting in the home screen's "Resume
* Conversation" list matched nothing (issue #261). The corpus that list renders
* comes from `GET /api/sessions/unified`, which reads the lifecycle log and every
* Claude transcript file: disk I/O the search path deliberately does not do (its
* no-fs property is what keeps a per-keystroke query cheap and traversal-free).
*
* This module is the seam between the two: a capped snapshot of the unified list
* that the search route reads synchronously, refreshed OUT of the request path.
* Two things fill it:
* 1. `/api/sessions/unified` writes it as a side effect (free, it just merged
* that list). The home screen calls that endpoint whenever it opens, which
* is the same screen the search box lives on, so it is warm in practice.
* 2. `ensureHistorySessionIndexFresh()`, fire-and-forget, single-flight,
* TTL-guarded, kicks the registered refresher when a search finds the
* snapshot stale. The caller never awaits it: the current query answers from
* the existing snapshot and the next one sees fresh data.
*
* OWNERSHIP: each item carries the `owner` of the session it came from, and rows
* not tied to any live/persisted session (host-wide transcript history) carry
* `owner: undefined`. `canAccessOwned()` then reproduces the unified route's rule
* exactly, in multi-user mode a non-admin sees neither other users' sessions nor
* unowned host-wide history, and in single-user mode every check short-circuits
* true. The snapshot is written UNSCOPED, so it must never be returned unfiltered.
*
* Key exports:
* - setHistorySessionIndex / getHistorySessionIndex: the snapshot accessors.
* - buildHistorySessionIndexItems: pure merged-list → index-item projection.
* - setHistoryIndexRefresher / ensureHistorySessionIndexFresh: the refresh hook.
*/
/** One past-session row in the snapshot. Mirrors what the search corpus needs, nothing more. */
export interface HistorySessionIndexItem {
/** Codeman session id (the search result's session id and dedupe key). */
sessionId: string;
/** Display name, may be empty for a transcript-only row. */
name: string;
/** Absolute working directory, the field issue #261 is about matching. */
workingDir: string;
/** Claude conversation UUID, when known: what a resume actually replays. */
claudeSessionId?: string;
/** Recency timestamp (lastActivityAt, else createdAt). */
timestamp: number;
/**
* Owning user, when the row is tied to a live or persisted session. `undefined`
* means host-wide transcript history, which only admins (or single-user mode)
* may see, the same rule `/api/sessions/unified` applies.
*/
owner?: string;
/** True when the session is still in the live map (search harvests those directly). */
live: boolean;
}
/** Hard cap on snapshot size, so a host with thousands of transcripts stays bounded. */
export const HISTORY_INDEX_MAX_ITEMS = 400;
/** How long a snapshot is considered fresh before a search triggers a background refresh. */
export const HISTORY_INDEX_TTL_MS = 60_000;
interface HistorySessionIndexSnapshot {
items: HistorySessionIndexItem[];
/** Epoch ms of the last write; 0 when never populated. */
updatedAt: number;
}
let snapshot: HistorySessionIndexSnapshot = { items: [], updatedAt: 0 };
let refresher: (() => Promise<void>) | null = null;
let refreshInFlight = false;
/** The merged-list shape this module projects from (a subset of `UnifiedSessionItem`). */
export interface MergedSessionLike {
sessionId: string;
name?: string;
workingDir?: string;
claudeSessionId?: string;
createdAt?: number;
lastActivityAt?: number;
}
/**
* Project a merged unified list into index items. PURE, the caller supplies the
* owner lookup and the live-id set it already has in hand.
*
* Rows with no working directory AND no name are dropped: they can never match a
* query in a useful way and would only consume the cap.
*
* @param merged unified-list items, newest-first (the order the merge returns)
* @param ownerById owner of a session id, for rows tied to a live/persisted session
* @param liveIds session ids currently in the live map
*/
export function buildHistorySessionIndexItems(
merged: MergedSessionLike[],
ownerById: Map<string, string | undefined>,
liveIds: Set<string>
): HistorySessionIndexItem[] {
const items: HistorySessionIndexItem[] = [];
for (const m of merged) {
if (items.length >= HISTORY_INDEX_MAX_ITEMS) break;
const name = m.name ?? '';
const workingDir = m.workingDir ?? '';
if (!name && !workingDir) continue;
items.push({
sessionId: m.sessionId,
name,
workingDir,
claudeSessionId: m.claudeSessionId,
timestamp: m.lastActivityAt ?? m.createdAt ?? 0,
owner: ownerById.get(m.sessionId),
live: liveIds.has(m.sessionId),
});
}
return items;
}
/** Replace the snapshot. Items are capped defensively even if the caller already did. */
export function setHistorySessionIndex(items: HistorySessionIndexItem[], now = Date.now()): void {
snapshot = { items: items.slice(0, HISTORY_INDEX_MAX_ITEMS), updatedAt: now };
}
/**
* Read the snapshot. The returned array is UNSCOPED, callers must apply the
* per-item ownership check before exposing any of it.
*/
export function getHistorySessionIndex(): HistorySessionIndexSnapshot {
return snapshot;
}
/** True when the snapshot has never been written, or is older than the TTL. */
export function isHistorySessionIndexStale(now = Date.now(), ttlMs = HISTORY_INDEX_TTL_MS): boolean {
return snapshot.updatedAt === 0 || now - snapshot.updatedAt > ttlMs;
}
/**
* Register the rebuild function. Called once by the session routes, which own the
* transcript scanner and the stores the unified list is merged from.
*/
export function setHistoryIndexRefresher(fn: (() => Promise<void>) | null): void {
refresher = fn;
}
/**
* Kick a background rebuild if the snapshot is stale. Returns immediately,
* NEVER await this from a request handler, that is the whole point: the search
* path answers from the current snapshot and stays free of disk I/O.
*/
export function ensureHistorySessionIndexFresh(now = Date.now()): void {
if (refreshInFlight || !refresher || !isHistorySessionIndexStale(now)) return;
refreshInFlight = true;
void refresher()
.catch(() => {
// A failed rebuild leaves the previous snapshot in place; the next search retries.
})
.finally(() => {
refreshInFlight = false;
});
}
/** Test hook: drop the snapshot and any registered refresher. */
export function resetHistorySessionIndex(): void {
snapshot = { items: [], updatedAt: 0 };
refresher = null;
refreshInFlight = false;
}