diff --git a/lib/public/css/session-folders.css b/lib/public/css/session-folders.css index 91d05aec8..f8b63b526 100644 --- a/lib/public/css/session-folders.css +++ b/lib/public/css/session-folders.css @@ -30,6 +30,22 @@ background: rgba(var(--overlay-rgb), 0.05); color: var(--text-dimmer); font-size: 9.5px; font-weight: 600; text-align: center; font-variant-numeric: tabular-nums; } +.session-count-summary { position: relative; } +.session-count-summary::after { + content: attr(data-count-detail); position: absolute; right: -4px; bottom: calc(100% + 6px); z-index: 20; + width: max-content; max-width: min(220px, calc(100vw - 24px)); padding: 5px 7px; border: 1px solid var(--border); + border-radius: 7px; background: var(--bg-alt, var(--sidebar-bg)); color: var(--text); box-shadow: 0 4px 14px rgba(var(--shadow-rgb), 0.3); + font-size: 10.5px; font-weight: 500; line-height: 1.25; letter-spacing: 0; white-space: nowrap; + opacity: 0; visibility: hidden; transform: translateY(2px); pointer-events: none; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s; +} +.session-folder-toggle:hover .session-count-summary::after, +.session-folder-toggle:focus-visible .session-count-summary::after, +.session-date-group-count:hover::after, .session-date-group-count:focus-visible::after, +.session-search-count:hover::after, .session-search-count:focus-visible::after { + opacity: 1; visibility: visible; transform: translateY(0); +} +.session-date-group-count:focus-visible, .session-search-count:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .session-folder-body[hidden] { display: none; } .session-folder-body { padding-left: 4px; border-left: 1px solid var(--border-subtle, var(--border)); margin-left: 7px; } @@ -84,7 +100,7 @@ .session-search-count { position: absolute; right: 30px; top: 50%; transform: translateY(-50%); min-width: 16px; padding: 0 5px; height: 15px; line-height: 15px; border-radius: 999px; background: rgba(var(--overlay-rgb), 0.08); color: var(--text-dimmer); font-size: 9.5px; font-weight: 600; text-align: center; - pointer-events: none; + cursor: help; } .session-search-count:empty { display: none; } .session-search-clear { @@ -162,8 +178,9 @@ .session-folder-tool svg { width: 12px; height: 12px; display: block; } .session-folder-tool:hover { color: var(--text); background: rgba(var(--overlay-rgb), 0.08); } -/* The Favorites star: unfilled and revealed on hover or focus, filled and - always visible once the session is a favorite. */ +/* The Favorites star: unfilled and revealed on hover or focus, filled when + selected. Desktop rows reveal it in the shared trailing slot; mobile keeps + the selected star visible as a persistent touch affordance. */ .session-folder-star-btn { position: absolute; right: 52px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; z-index: 1; display: flex; align-items: center; justify-content: center; border: 0; border-radius: 999px; background: transparent; @@ -172,13 +189,19 @@ .session-folder-star-btn svg, .mobile-session-star svg { width: 12px; height: 12px; display: block; } .session-item:hover .session-folder-star-btn, .session-item:focus-within .session-folder-star-btn { opacity: 0.82; pointer-events: auto; } .session-folder-star-btn:hover { opacity: 1; color: var(--text); background: rgba(var(--overlay-rgb), 0.08); } -.session-folder-star-btn.is-favorite, .mobile-session-star.is-favorite { color: var(--accent); opacity: 1; pointer-events: auto; } +.session-folder-star-btn.is-favorite, .mobile-session-star.is-favorite { color: var(--accent); } +.mobile-session-star.is-favorite { opacity: 1; pointer-events: auto; } .session-folder-star-btn.is-favorite svg, .mobile-session-star.is-favorite svg { fill: currentColor; } .session-folder-star-btn:focus-visible, .mobile-session-star:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } -/* Desktop row actions are a real flex column in the row, not three unrelated - offsets over the age and linked-work metadata. Keeping the full cluster's - footprint at rest also prevents the title from jumping when actions appear. */ +/* Age and actions share one intrinsic trailing slot. CSS grid takes the wider + of the two without adding their widths, so hover/focus swaps content without + moving the title, linked work, presence, or row controls. */ +.session-item .session-row-trailing { + flex: 0 0 auto; display: grid; align-items: center; justify-items: end; min-width: 0; +} +.session-item .session-row-trailing > .session-item-age, +.session-item .session-row-trailing > .session-row-actions { grid-area: 1 / 1; } .session-item .session-row-actions { flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end; gap: 4px; } @@ -186,6 +209,9 @@ .session-item .session-row-actions > .session-close-btn { position: static; flex: 0 0 18px; transform: none; } +.session-item .session-row-trailing .session-folder-star-btn.is-favorite { opacity: 0; pointer-events: none; } +.session-item:hover .session-row-trailing .session-folder-star-btn.is-favorite, +.session-item:focus-within .session-row-trailing .session-folder-star-btn.is-favorite { opacity: 0.82; pointer-events: auto; } /* Mobile: touch-sized targets, always-visible actions, no drag affordances. */ .session-folder.is-mobile .session-folder-header { min-height: 40px; margin-top: 8px; } @@ -237,7 +263,10 @@ .session-folder-view-note { font-size: 12px; line-height: 1.45; color: var(--text-muted); } @media (prefers-reduced-motion: reduce) { - .session-folder, .session-folder-chevron, .session-folder-toggle, .session-folder-tool { transition: none; } + .session-folder, .session-folder-chevron, .session-folder-toggle, .session-folder-tool, .session-count-summary::after { transition: none; } + .session-row-trailing .session-item-age, + .session-row-trailing .session-folder-star-btn, + .session-row-trailing .session-close-btn { transition: none; } .session-folder-body.folder-collapse-animating { will-change: auto; } } diff --git a/lib/public/css/session-github.css b/lib/public/css/session-github.css index b4ae4a0ca..6cee5852a 100644 --- a/lib/public/css/session-github.css +++ b/lib/public/css/session-github.css @@ -2,7 +2,7 @@ .session-work-column { display: flex; flex-direction: column; - gap: 3px; + gap: 2px; flex: 1 1 auto; min-width: 0; overflow: visible; @@ -28,10 +28,71 @@ line-height: 18px; } .session-work-column .session-row-github-links { - flex-wrap: wrap; - gap: 3px 5px; - overflow: visible; + flex-wrap: nowrap; + gap: 3px; + overflow: hidden; + line-height: 16px; +} +.session-row-github-links .session-github-link { + min-width: 0; + height: 18px; + box-sizing: border-box; + gap: 3px; + padding: 0 4px; + font-size: 10.5px; +} +.session-row-github-links .session-linear-link { + flex: 1 1 0; + min-width: 0; + max-width: 100%; + overflow: hidden; +} +.session-row-github-links:has(.github-kind-pr, .github-kind-issue) .session-linear-link { max-width: calc(100% - 76px); } +.linear-work-state-compact { display: none; } +.session-row-github-links .linear-work-id { + flex: 0 1 auto; + min-width: 0; + max-width: 52%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-row-github-links .linear-work-state { + display: block; + flex: 1 4 0; + min-width: 0; + max-width: 52px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +.session-row-github-links .linear-work-state-full { display: none; } +.session-row-github-links .linear-work-state-compact { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-row-github-links .linear-work-title { + flex: 1 5 0; + min-width: 0; + max-width: 34px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-row-github-links .github-kind-pr, +.session-row-github-links .github-kind-issue { flex: 0 0 auto; } +.session-row-github-links .github-work-brand { display: none; } +.session-row-github-links .github-work-brand svg, +.session-row-github-links .github-work-status svg { width: 11px; height: 11px; } +.session-row-github-links .github-work-more { height: 18px; line-height: 16px; padding: 0 3px; font-size: 10.5px; } +.session-row-github-links .linear-work-brand svg { width: 11px; height: 11px; } +.session-row-github-links .linear-work-brand { flex: none; } +.session-row-github-links .session-linear-link, +.session-row-github-links .session-github-link, +.session-row-github-links .github-work-more { touch-action: manipulation; } +.session-row-github-links { min-height: 18px; } .session-github-link { display: inline-flex; align-items: center; @@ -60,10 +121,25 @@ } .session-item:has(.session-work-column) { + position: relative; + container-type: inline-size; height: auto; - min-height: 52px; + min-height: 46px; contain: layout; } +@container (max-width: 280px) { + .session-row-github-links .linear-work-title { display: none; } +} +.session-item:has(.session-work-column) > .session-vendor-mark, +.session-item:has(.session-work-column) > .session-work-column, +.session-item:has(.session-work-column) > .session-presence, +.session-item:has(.session-work-column) > .session-row-trailing { align-self: flex-start; margin-top: 5px; } +.session-item:has(.session-work-column) .session-row-github-links { + position: absolute; + left: 30px; + right: 44px; + bottom: 3px; +} .github-kind-pr.github-state-closed .github-work-status svg { color: #f85149; } @media (max-width: 600px) { @@ -117,6 +193,11 @@ .github-work-entry-meta { color: var(--text-muted, #888); font-size: 11px; overflow-wrap: anywhere; } @media (max-width: 600px) { #header-github-links { max-width: 120px; } - .mobile-session-item:has(.session-work-column) { align-items: flex-start; } - .mobile-session-item .github-work-more { min-height: 28px; } + .mobile-session-item:has(.session-work-column) { position: relative; container-type: inline-size; align-items: flex-start; min-height: 70px; padding-block: 8px; } + .mobile-session-item:has(.session-work-column) .session-work-column { position: static; } + .mobile-session-item:has(.session-work-column) .session-row-github-links { position: absolute; left: 44px; right: 54px; bottom: 8px; } + .mobile-session-item .session-row-github-links .session-github-link, + .mobile-session-item .session-row-github-links .github-work-more { min-height: 28px; height: 28px; } + .mobile-session-item .session-row-github-links .github-work-more { padding-inline: 2px; } + .mobile-session-item .session-row-github-links { min-height: 28px; } } diff --git a/lib/public/modules/session-date-group-header.js b/lib/public/modules/session-date-group-header.js index d428a4796..cdf3b472b 100644 --- a/lib/public/modules/session-date-group-header.js +++ b/lib/public/modules/session-date-group-header.js @@ -3,6 +3,7 @@ import { getWs } from './ws-ref.js'; import { showConfirm } from './app-misc.js'; import { describeGroupDelete } from './session-delete-scope.js'; import { showToast } from './utils.js'; +import { renderSidebarCount } from './sidebar-session-counts.js'; function confirmClear(label, ids, sessions) { if (!ids.length) return; @@ -22,7 +23,7 @@ function confirmClear(label, ids, sessions) { }); } -export function renderDateGroupHeader(dateLabel, folderLabel, ids, sessions, surface, searching) { +export function renderDateGroupHeader(dateLabel, folderLabel, ids, sessions, surface, searching, displayCounts) { var header = document.createElement("div"); header.className = "session-group-header session-date-group-header" + (surface === "mobile" ? " is-mobile" : ""); header.setAttribute("role", "heading"); @@ -33,10 +34,7 @@ export function renderDateGroupHeader(dateLabel, folderLabel, ids, sessions, sur label.textContent = dateLabel; header.appendChild(label); - var count = document.createElement("span"); - count.className = "session-date-group-count"; - count.textContent = String(ids.length); - count.setAttribute("aria-label", ids.length + (ids.length === 1 ? " session" : " sessions")); + var count = renderSidebarCount("session-date-group-count", displayCounts || { roots: ids.length, workers: 0 }, true); header.appendChild(count); if ((!store.get('permissions') || store.get('permissions').sessionDelete !== false) && ids.length) { diff --git a/lib/public/modules/session-folder-toolbar.js b/lib/public/modules/session-folder-toolbar.js index 054546588..2970eeeb4 100644 --- a/lib/public/modules/session-folder-toolbar.js +++ b/lib/public/modules/session-folder-toolbar.js @@ -13,6 +13,7 @@ import { openListSearch, closeListSearch, clearListSearchText, setListSearchQuery, rememberListSearchCaret, getListSearchQuery, getListSearchMatchIds, listSearchNeedsRender } from './session-list-search.js'; +import { renderSidebarCount } from './sidebar-session-counts.js'; // Visible one wins: the desktop list and the mobile sheet can both be in the DOM. function visible(selector) { @@ -44,7 +45,7 @@ export function folderToolbarNeedsRender(state, previous) { return folderDraftNeedsRender(state, previous) || listSearchNeedsRender(state, previous); } -function renderSearchBar(bar, surface) { +function renderSearchBar(bar, surface, displayCounts) { var state = store.get('sessionSearch'); var query = getListSearchQuery(); var matches = getListSearchMatchIds(); @@ -92,9 +93,8 @@ function renderSearchBar(bar, surface) { }, 0); }); - var count = document.createElement("span"); - count.className = "session-search-count"; - if (query && matches !== null) count.textContent = String(matches.size); + var count = renderSidebarCount("session-search-count", displayCounts || { roots: matches ? matches.size : 0, workers: 0 }, true); + if (!query || matches === null) count.textContent = ""; var clear = document.createElement("button"); clear.type = "button"; clear.className = "session-search-clear"; @@ -130,7 +130,7 @@ function renderSearchBar(bar, surface) { }); } -export function renderFolderToolbar(surface) { +export function renderFolderToolbar(surface, displayCounts) { var create = store.get('sessionFolderCreate'); var search = store.get('sessionSearch'); var wrap = document.createElement("div"); @@ -140,7 +140,7 @@ export function renderFolderToolbar(surface) { wrap.appendChild(bar); if (search && search.open) { - renderSearchBar(bar, surface); + renderSearchBar(bar, surface, displayCounts); return wrap; } diff --git a/lib/public/modules/session-github.js b/lib/public/modules/session-github.js index 6fb242f82..ce1585f56 100644 --- a/lib/public/modules/session-github.js +++ b/lib/public/modules/session-github.js @@ -21,6 +21,12 @@ function safeLinks(session) { function label(link) { return (link.kind === 'pr' ? 'PR #' : 'Issue #') + link.number; } +function compactLinearState(state) { + var value = String(state || ''); + if (/^in progress$/i.test(value)) return 'Active'; + if (/^cancell?ed$/i.test(value)) return 'Canceled'; + return value.length > 9 ? value.slice(0, 8) + '\u2026' : value; +} function anchor(url, title, content, className) { return '' + content + ''; } @@ -43,7 +49,7 @@ export function githubWorkMarkup(session, detailed, expanded, sessionRow) { return '' + displayed.map(function (link) { if (link.provider === 'linear') { var description = link.identifier + ' · ' + link.title + ' · ' + link.stateName + (link.stale ? ' · Last known status' : ''); - return anchor(link.url, description, LINEAR_MARK + '' + escapeHtml(link.identifier) + '' + escapeHtml(link.title) + '' + escapeHtml(link.stateName) + '', 'session-github-link session-linear-link'); + return anchor(link.url, description, LINEAR_MARK + '' + escapeHtml(link.identifier) + '' + escapeHtml(link.title) + '' + escapeHtml(link.stateName) + '', 'session-github-link session-linear-link'); } var state = ['open', 'closed', 'merged', 'draft'].indexOf(link.state) >= 0 ? link.state : 'unknown'; var icon = link.kind === 'pr' ? (state === 'merged' ? 'git-merge' : state === 'closed' ? 'git-pull-request-closed' : state === 'draft' ? 'git-pull-request-draft' : 'git-pull-request') : (state === 'closed' ? 'circle-check' : 'circle-dot'); diff --git a/lib/public/modules/session-presence-ui.js b/lib/public/modules/session-presence-ui.js index 3a446aba9..32a0a02cc 100644 --- a/lib/public/modules/session-presence-ui.js +++ b/lib/public/modules/session-presence-ui.js @@ -38,7 +38,7 @@ export function renderSessionPresence(row, sessionId) { container.appendChild(more); } - var anchor = row.querySelector(".session-item-age, .mobile-worker-generation, .mobile-session-unread, .session-row-actions, .mobile-session-star"); + var anchor = row.querySelector(".session-row-trailing, .session-item-age, .mobile-worker-generation, .mobile-session-unread, .session-row-actions, .mobile-session-star"); row.insertBefore(container, anchor || null); return container; } diff --git a/lib/public/modules/sidebar-mobile.js b/lib/public/modules/sidebar-mobile.js index 7075747cd..3ced0b66f 100644 --- a/lib/public/modules/sidebar-mobile.js +++ b/lib/public/modules/sidebar-mobile.js @@ -34,6 +34,7 @@ import { import { store } from './store.js'; import { getWs } from './ws-ref.js'; import { dismissOverlayPanels, closeSidebar } from './sidebar.js'; +import { openWorkerWithDriver } from './split-view.js'; import { switchProject, getCachedProjects } from './app-projects.js'; import { showHomeHub } from './app-home-hub.js'; import { openHomeChat } from './home-mate-chat.js'; @@ -45,6 +46,7 @@ import { renderMobileDriverHierarchy, renderMobileOrphanHierarchy } from './sidebar-session-hierarchy.js'; +import { summarizeSidebarItems } from './sidebar-session-counts.js'; import { cancelSessionInlineRename, closeSessionContextMenu } from './session-context-menu.js'; // --- Mobile state --- @@ -443,15 +445,17 @@ function createMobileSessionItem(s, options) { openSessionContextMenu(el, s); }); - (function (id) { + (function (session, worker) { el.addEventListener("click", function () { if (getWs() && store.get('connected')) { - getWs().send(JSON.stringify({ type: "switch_session", id: id })); + if (!worker || !openWorkerWithDriver(session)) { + getWs().send(JSON.stringify({ type: "switch_session", id: session.id })); + } } if (dismissOverlayPanels) dismissOverlayPanels(); closeMobileSheet(); }); - })(s.id); + })(s, !!itemOptions.worker); return el; } @@ -745,15 +749,16 @@ export function renderMobileSessionsInto(container) { var folderCtx = { surface: "mobile", searching: matchIds !== null, + matchIds: matchIds, sessions: getCachedSessions(), renderItem: function (it) { if (it.type === "loop") return createMobileLoopGroup(it.loopId, it.children, it.groupKey); - if (it.type === "driver-hierarchy") return renderMobileDriverHierarchy(it.root, createMobileSessionItem, refreshMobileChatSheet); - if (it.type === "orphan-workers") return renderMobileOrphanHierarchy(it.workers, createMobileSessionItem, refreshMobileChatSheet); + if (it.type === "driver-hierarchy") return renderMobileDriverHierarchy(it.root, createMobileSessionItem, refreshMobileChatSheet, matchIds); + if (it.type === "orphan-workers") return renderMobileOrphanHierarchy(it.workers, createMobileSessionItem, refreshMobileChatSheet, matchIds); return createMobileSessionItem(it.data); }, }; - container.appendChild(renderFolderToolbar("mobile")); + container.appendChild(renderFolderToolbar("mobile", summarizeSidebarItems(filterItemsBySearch(items, matchIds), matchIds))); container.appendChild(renderFavoritesSection(layout, folderCtx)); renderOrdinarySections(layout, folderCtx, container); } diff --git a/lib/public/modules/sidebar-session-counts.js b/lib/public/modules/sidebar-session-counts.js new file mode 100644 index 000000000..3b448e8e3 --- /dev/null +++ b/lib/public/modules/sidebar-session-counts.js @@ -0,0 +1,65 @@ +// Display-only sidebar counts. Destructive Clear actions keep their separate +// authoritative session-id scope and cascade semantics. + +function sessionId(session) { + return session && typeof session.id === "number" ? session.id : null; +} + +function addSession(session, roots, workers) { + var id = sessionId(session); + if (id === null) return; + if (session.sessionRole === "worker") workers.add(id); + else roots.add(id); +} + +function visibleWorker(worker, driver, matchIds) { + return matchIds === null || matchIds === undefined || matchIds.has(worker.id) || matchIds.has(driver.id); +} + +function addItem(item, roots, workers, matchIds) { + if (!item) return; + if (item.type === "session") { + addSession(item.data, roots, workers); + return; + } + if (item.type === "driver-hierarchy") { + addSession(item.root && item.root.driver, roots, workers); + var children = item.root && item.root.workers || []; + for (var i = 0; i < children.length; i++) if (visibleWorker(children[i], item.root.driver, matchIds)) addSession(children[i], roots, workers); + return; + } + var sessions = item.type === "orphan-workers" ? item.workers : item.type === "split-group" ? item.members : item.type === "loop" ? item.children : []; + for (var j = 0; j < (sessions || []).length; j++) { + if (item.type === "orphan-workers" && matchIds !== null && matchIds !== undefined && !matchIds.has(sessions[j].id)) continue; + addSession(sessions[j], roots, workers); + } +} + +export function summarizeSidebarItems(items, matchIds) { + var roots = new Set(); + var workers = new Set(); + for (var i = 0; i < (items || []).length; i++) addItem(items[i], roots, workers, matchIds); + return { roots: roots.size, workers: workers.size }; +} + +export function summarizeSidebarUnits(units, matchIds) { + return summarizeSidebarItems((units || []).map(function (unit) { return unit.item; }), matchIds); +} + +export function sidebarCountLabel(counts, short) { + var roots = counts && counts.roots || 0; + var workers = counts && counts.workers || 0; + if (short) return roots + (roots === 1 ? " Driver" : " Drivers") + " · " + workers + (workers === 1 ? " Split Worker" : " Split Workers"); + return roots + (roots === 1 ? " Driver session" : " Driver sessions") + " and " + workers + (workers === 1 ? " Split Worker session" : " Split Worker sessions"); +} + +export function renderSidebarCount(className, counts, focusable) { + var count = document.createElement("span"); + count.className = className + " session-count-summary"; + count.textContent = String(counts.roots); + count.dataset.countDetail = sidebarCountLabel(counts, true); + count.setAttribute("aria-label", sidebarCountLabel(counts, false)); + count.title = sidebarCountLabel(counts, false); + if (focusable) count.tabIndex = 0; + return count; +} diff --git a/lib/public/modules/sidebar-session-folders-ui.js b/lib/public/modules/sidebar-session-folders-ui.js index c70b734de..44e96bdc5 100644 --- a/lib/public/modules/sidebar-session-folders-ui.js +++ b/lib/public/modules/sidebar-session-folders-ui.js @@ -22,6 +22,7 @@ import { renderNewSessionButton, renderCreateRow } from './session-create-form.j import { restoreFolderCollapse, toggleFolderCollapse } from './session-folder-collapse.js'; import { groupUnitsByDate, groupRootSessionIds } from './session-date-groups.js'; import { renderDateGroupHeader } from './session-date-group-header.js'; +import { summarizeSidebarUnits, sidebarCountLabel, renderSidebarCount } from './sidebar-session-counts.js'; // Mutable UI state (open menu, drag in flight, the layout each surface last // rendered) lives in the store; this module only owns the functions. @@ -269,15 +270,6 @@ function wireUnitDrop(wrapper, containerId, key) { // --- Rendering --- -function countUnits(units) { - var total = 0; - for (var i = 0; i < units.length; i++) { - var item = units[i].item; - total += item.type === "driver-hierarchy" ? 1 + item.root.workers.length : 1; - } - return total; -} - function renderUnit(unit, containerId, ctx) { var wrapper = document.createElement("div"); wrapper.className = "session-folder-unit"; @@ -317,14 +309,17 @@ function renderSection(section, ctx, kind) { toggle.className = "session-folder-toggle"; toggle.setAttribute("aria-expanded", String(!section.collapsed)); toggle.setAttribute("aria-controls", bodyId); + var counts = summarizeSidebarUnits(section.units, ctx.matchIds); + toggle.setAttribute("aria-label", (section.collapsed ? "Expand " : "Collapse ") + section.label + ". " + sidebarCountLabel(counts, false)); var icon = isFavorites ? "star" : (kind === "unfiled" ? "inbox" : "folder"); toggle.innerHTML = '' + iconHtml("chevron-right") + "" + '' + iconHtml(icon) + "" + - '' + escapeHtml(section.label) + "" + - '' + countUnits(section.units) + ""; + '' + escapeHtml(section.label) + ""; + toggle.appendChild(renderSidebarCount("session-folder-count", counts, false)); toggle.addEventListener("click", function () { if (consumeFolderClickSuppress()) return; toggleFolderCollapse(root, body, toggle, surface, section.id, function (collapsed) { + toggle.setAttribute("aria-label", (collapsed ? "Expand " : "Collapse ") + section.label + ". " + sidebarCountLabel(counts, false)); sendFolderOp({ op: "set_collapsed", containerKey: section.id, collapsed: collapsed }); }); }); @@ -362,7 +357,7 @@ function renderSection(section, ctx, kind) { if (dateGroups) { for (var g = 0; g < dateGroups.length; g++) { var ids = groupRootSessionIds(dateGroups[g].units); - body.appendChild(renderDateGroupHeader(dateGroups[g].label, section.label, ids, ctx.sessions || [], surface, !!ctx.searching)); + body.appendChild(renderDateGroupHeader(dateGroups[g].label, section.label, ids, ctx.sessions || [], surface, !!ctx.searching, summarizeSidebarUnits(dateGroups[g].units, ctx.matchIds))); for (var gu = 0; gu < dateGroups[g].units.length; gu++) body.appendChild(renderUnit(dateGroups[g].units[gu], section.id, ctx)); } } else { diff --git a/lib/public/modules/sidebar-session-hierarchy.js b/lib/public/modules/sidebar-session-hierarchy.js index 8a091270e..b7a0bf5e2 100644 --- a/lib/public/modules/sidebar-session-hierarchy.js +++ b/lib/public/modules/sidebar-session-hierarchy.js @@ -32,7 +32,15 @@ export function currentWorkerIds() { var result = new Set(); var groups = store.get('splitGroups') || []; for (var i = 0; i < groups.length; i++) { - if (groups[i].pair && Number.isInteger(groups[i].pair.workerId)) result.add(groups[i].pair.workerId); + var pair = groups[i].pair; + if (!pair) continue; + if (Array.isArray(pair.workerIds)) { + for (var j = 0; j < pair.workerIds.length; j++) { + if (Number.isInteger(pair.workerIds[j])) result.add(pair.workerIds[j]); + } + } else if (Number.isInteger(pair.workerId)) { + result.add(pair.workerId); + } } return result; } @@ -55,22 +63,23 @@ export function hierarchyItemMatches(item, matchIds) { return true; } -function expanded(surface, key, workers, matchIds) { +function expansionKey(key) { + return String(store.get('currentSlug') || '') + ":" + key; +} + +function expanded(surface, key) { var state = expansionBySurface[surface]; - if (state.has(key)) return state.get(key); - return defaultHierarchyExpanded(workers, matchIds, currentWorkerIds()); + var scopedKey = expansionKey(key); + if (state.has(scopedKey)) return state.get(scopedKey); + return defaultHierarchyExpanded(); } -export function defaultHierarchyExpanded(workers, matchIds, current) { - var currentIds = current || new Set(); - for (var i = 0; i < workers.length; i++) { - if (workers[i].active || currentIds.has(workers[i].id) || (matchIds !== null && matchIds.has(workers[i].id))) return true; - } +export function defaultHierarchyExpanded() { return false; } function toggle(surface, key, wasExpanded, rerender) { - expansionBySurface[surface].set(key, !wasExpanded); + expansionBySurface[surface].set(expansionKey(key), !wasExpanded); rerender(); } @@ -82,7 +91,7 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc var wrapper = document.createElement("div"); wrapper.className = "session-driver-hierarchy"; var key = "driver:" + root.driver.id; - var isExpanded = expanded("desktop", key, root.workers, matchIds); + var isExpanded = expanded("desktop", key); var childrenId = "session-workers-" + root.driver.id; var header = document.createElement("div"); header.className = "session-driver-header"; @@ -105,7 +114,7 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc event.preventDefault(); event.stopPropagation(); var nextExpanded = !isExpanded; - expansionBySurface.desktop.set(key, nextExpanded); + expansionBySurface.desktop.set(expansionKey(key), nextExpanded); control.setAttribute("aria-expanded", String(nextExpanded)); control.setAttribute("aria-label", (nextExpanded ? "Collapse" : "Expand") + " Workers for " + (root.driver.title || "New Session")); children.hidden = !nextExpanded; @@ -127,7 +136,7 @@ export function renderDesktopDriverHierarchy(root, renderSession, rerender, matc export function renderDesktopOrphanHierarchy(workers, renderSession, rerender, matchIds) { var wrapper = document.createElement("div"); wrapper.className = "session-driver-hierarchy session-orphan-hierarchy"; - var isExpanded = expanded("desktop", "orphan", workers, matchIds); + var isExpanded = expanded("desktop", "orphan"); var control = document.createElement("button"); control.type = "button"; control.className = "session-orphan-toggle"; @@ -152,11 +161,11 @@ export function renderDesktopOrphanHierarchy(workers, renderSession, rerender, m return wrapper; } -export function renderMobileDriverHierarchy(root, renderSession, rerender) { +export function renderMobileDriverHierarchy(root, renderSession, rerender, matchIds) { var wrapper = document.createElement("div"); wrapper.className = "mobile-driver-hierarchy"; var key = "driver:" + root.driver.id; - var isExpanded = expanded("mobile", key, root.workers, null); + var isExpanded = expanded("mobile", key); var childrenId = "mobile-session-workers-" + root.driver.id; var header = document.createElement("div"); header.className = "mobile-driver-header"; @@ -180,16 +189,17 @@ export function renderMobileDriverHierarchy(root, renderSession, rerender) { children.hidden = !isExpanded; var current = currentWorkerIds(); for (var i = 0; i < root.workers.length; i++) { + if (!visibleWorker(root.workers[i], root.driver, matchIds === undefined ? null : matchIds)) continue; children.appendChild(renderSession(root.workers[i], { worker: true, current: current.has(root.workers[i].id) })); } wrapper.appendChild(children); return wrapper; } -export function renderMobileOrphanHierarchy(workers, renderSession, rerender) { +export function renderMobileOrphanHierarchy(workers, renderSession, rerender, matchIds) { var wrapper = document.createElement("div"); wrapper.className = "mobile-driver-hierarchy mobile-orphan-hierarchy"; - var isExpanded = expanded("mobile", "orphan", workers, null); + var isExpanded = expanded("mobile", "orphan"); var control = document.createElement("button"); control.type = "button"; control.className = "mobile-orphan-toggle"; @@ -207,6 +217,7 @@ export function renderMobileOrphanHierarchy(workers, renderSession, rerender) { children.hidden = !isExpanded; var current = currentWorkerIds(); for (var i = 0; i < workers.length; i++) { + if (matchIds !== null && matchIds !== undefined && !matchIds.has(workers[i].id)) continue; children.appendChild(renderSession(workers[i], { worker: true, current: current.has(workers[i].id) })); } wrapper.appendChild(children); diff --git a/lib/public/modules/sidebar-sessions.js b/lib/public/modules/sidebar-sessions.js index e3e8e2835..085a6dce7 100644 --- a/lib/public/modules/sidebar-sessions.js +++ b/lib/public/modules/sidebar-sessions.js @@ -13,7 +13,7 @@ import { showConfirm } from './app-misc.js'; import { getUpcomingSchedules } from './scheduler.js'; import { refreshMobileChatSheet } from './sidebar-mobile.js'; import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js'; -import { openGroup, separateGroup } from './split-view.js'; +import { openGroup, openWorkerWithDriver, separateGroup } from './split-view.js'; import { groupedSessionIds } from './split-group-helpers.js'; import { resolvePreferredVendor } from './default-vendor.js'; import { @@ -31,6 +31,7 @@ import { renderDesktopDriverHierarchy, renderDesktopOrphanHierarchy } from './sidebar-session-hierarchy.js'; +import { summarizeSidebarItems } from './sidebar-session-counts.js'; import { cancelSessionInlineRename, closeSessionContextMenu, @@ -841,12 +842,14 @@ function renderSessionItem(s, options) { el.appendChild(workerLabel); } + var trailing = document.createElement("span"); + trailing.className = "session-row-trailing"; var ageText = relativeTime(s.lastActivity || 0); if (ageText) { var age = document.createElement("span"); age.className = "session-item-age"; age.textContent = ageText; - el.appendChild(age); + trailing.appendChild(age); } // Right-click / long-press: context menu @@ -874,13 +877,16 @@ function renderSessionItem(s, options) { if (isFavoriteSession(s)) el.classList.add("is-favorite"); } appendSessionCloseButton(actions, s); - if (actions.childNodes.length) el.appendChild(actions); + if (actions.childNodes.length) trailing.appendChild(actions); + if (trailing.childNodes.length) el.appendChild(trailing); - el.addEventListener("click", (function (id) { + el.addEventListener("click", (function (session, worker) { return function () { if (getWs() && store.get('connected')) { var pendingQuery = getListSearchQuery() || ""; - getWs().send(JSON.stringify({ type: "switch_session", id: id })); + if (!worker || !openWorkerWithDriver(session)) { + getWs().send(JSON.stringify({ type: "switch_session", id: session.id })); + } dismissOverlayPanels(); closeSidebar(); if (pendingQuery) { @@ -888,7 +894,7 @@ function renderSessionItem(s, options) { } } }; - })(s.id)); + })(s, !!itemOptions.worker)); // Presence avatars (multi-user) renderSessionPresence(el, String(s.id)); @@ -1071,6 +1077,7 @@ export function renderSessionList(sessions) { var folderCtx = { surface: "desktop", searching: matchIds !== null, + matchIds: matchIds, sessions: cachedSessions, renderItem: function (it) { if (it.type === "loop") return renderLoopGroup(it.loopId, it.children, it.groupKey) || document.createElement("div"); @@ -1085,7 +1092,7 @@ export function renderSessionList(sessions) { // custom folders, then Unfiled. Each header has its own New session button. var topActions = document.createElement("div"); topActions.className = "session-list-sticky-top"; - topActions.appendChild(renderFolderToolbar("desktop")); + topActions.appendChild(renderFolderToolbar("desktop", summarizeSidebarItems(visibleItems, matchIds))); getSessionListEl().appendChild(topActions); var regularContainer = document.createElement("div"); diff --git a/lib/public/modules/split-group-helpers.js b/lib/public/modules/split-group-helpers.js index 911dc094a..97a406dc3 100644 --- a/lib/public/modules/split-group-helpers.js +++ b/lib/public/modules/split-group-helpers.js @@ -72,6 +72,24 @@ export function isConfiguredWorker(groups, sessionId) { return false; } +export function resolveWorkerSplitTarget(worker, sessions, groups) { + if (!worker || worker.sessionRole !== "worker" || !worker.parentAvailable || !Number.isInteger(worker.parentSessionId)) return null; + var list = sessions || []; + var driver = null; + for (var i = 0; i < list.length; i++) { + if (list[i].id === worker.parentSessionId && list[i].sessionRole !== "worker") driver = list[i]; + } + if (!driver) return null; + var groupList = groups || []; + for (var j = 0; j < groupList.length; j++) { + var roles = splitGroupRoles(groupList[j]); + if (roles && roles.driverId === driver.id && roles.workerIds.indexOf(worker.id) !== -1) { + return { driver: driver, worker: worker, group: groupList[j] }; + } + } + return { driver: driver, worker: worker, group: null }; +} + export function splitWorkerCloseRequest(group, sessions, projectSlug, workerId, requestId) { var roles = splitGroupRoles(group); if (!roles || roles.version !== 2 || roles.workerIds.indexOf(workerId) === -1) return null; diff --git a/lib/public/modules/split-view.js b/lib/public/modules/split-view.js index 5ea36d86c..9d0143285 100644 --- a/lib/public/modules/split-view.js +++ b/lib/public/modules/split-view.js @@ -6,7 +6,7 @@ import { getWs } from './ws-ref.js'; import { getCachedSessions } from './sidebar-sessions.js'; import { refreshIcons } from './icons.js'; import { detachTuiView } from './session-tui-view.js'; -import { groupedSessionIds, findSplitGroup, isConfiguredWorker, splitGroupRoles, splitGroupMemberIds, splitGroupActiveAnchor, splitWorkerCloseRequest } from './split-group-helpers.js'; +import { groupedSessionIds, findSplitGroup, isConfiguredWorker, resolveWorkerSplitTarget, splitGroupRoles, splitGroupMemberIds, splitGroupActiveAnchor, splitWorkerCloseRequest } from './split-group-helpers.js'; import { syncPairChrome } from './split-pair-ui.js'; import { presentMarkdownEdit } from './filebrowser.js'; import { autoStartLoginIfNeeded } from './vendor-login.js'; @@ -250,6 +250,28 @@ export function openGroup(group) { return true; } +export function openWorkerWithDriver(worker) { + var target = resolveWorkerSplitTarget(worker, getCachedSessions(), store.get('splitGroups')); + if (!target) return false; + if (target.group) return openGroup(target.group); + detachTuiView(); + store.set({ + splitPanes: { + groupId: null, + panes: [paneForSession(target.driver.id), paneForSession(target.worker.id)], + }, + }); + var activeId = store.get('activeSessionId'); + if (activeId !== target.driver.id) { + var ws = getWs(); + if (isCurrentProjectSessionReady(store.snap(), ws)) { + ws.send(JSON.stringify({ type: "switch_session", id: target.driver.id })); + } + } + dismissSplitOverlays(); + return true; +} + // Reload survival: when the restored active session turns out to be a split // group member, reopen that group. Safe to call often -- no-ops unless a // group member is active natively with no split open. @@ -366,6 +388,8 @@ function handleSidebarSessionClick(event) { if (!sessionId) return; event.preventDefault(); event.stopImmediatePropagation(); + var session = sessionById(sessionId); + if (session && session.sessionRole === "worker" && openWorkerWithDriver(session)) return; switchNativeSession(sessionId); } diff --git a/test/fixtures/session-folders-dom/harness.js b/test/fixtures/session-folders-dom/harness.js index 2c9f17847..f57c54db3 100644 --- a/test/fixtures/session-folders-dom/harness.js +++ b/test/fixtures/session-folders-dom/harness.js @@ -258,6 +258,14 @@ test("initial render: Favorites, Unfiled, Driver is one unit holding its Workers ok(favStyle.maxHeight === "none" && favStyle.overflowY === "visible", "Favorites has no internal scroll rule: " + favStyle.maxHeight + "/" + favStyle.overflowY); ok(getComputedStyle(fav).position !== "sticky" && getComputedStyle(fav).margin === getComputedStyle(section("unfiled")).margin, "Favorites uses the same spacing as other folders"); ok(!newBtn("favorites") && newBtn("unfiled"), "Favorites has no New session action; Unfiled does"); + var unfiledCount = $(".session-folder-count", section("unfiled")); + ok(unfiledCount.textContent === "3", "folder headline counts three Driver/root sessions, not five rows including Workers"); + ok(unfiledCount.dataset.countDetail === "3 Drivers · 2 Split Workers" && /3 Driver sessions and 2 Split Worker sessions/.test($(".session-folder-toggle", section("unfiled")).getAttribute("aria-label")), "Worker detail is available without changing the headline count"); + $(".session-folder-toggle", section("unfiled")).focus(); + await wait(150); + ok(getComputedStyle(unfiledCount, "::after").opacity === "1", "keyboard focus reveals the Driver and Split Worker detail tooltip"); + var dateCount = $(".session-date-group-count", section("unfiled")); + ok(dateCount.textContent === "3" && dateCount.dataset.countDetail === "3 Drivers · 2 Split Workers", "date subsection uses the same root-only headline and Worker detail"); var alphaStar = starIn("unfiled", 1); ok(alphaStar && alphaStar.getAttribute("aria-pressed") === "false" && /Add to Favorites/.test(alphaStar.getAttribute("aria-label")), "an eligible root has an accessible outlined star"); ok(!row(4).getAttribute("draggable") && !$(".session-folder-move-btn", row(4)), "Workers have no drag or move control"); @@ -273,6 +281,7 @@ test("drag onto Favorites tags without moving, then reorder the curated tag view await sync(); ok(titlesIn("favorites").join() === "2,1", "Beta dropped above Alpha: " + titlesIn("favorites")); ok(titlesIn("unfiled").includes("1") && titlesIn("unfiled").includes("2"), "both actual placements remain Unfiled"); + ok($(".session-folder-count", section("favorites")).textContent === "2" && $(".session-folder-count", section("unfiled")).textContent === "3", "Favorites and Unfiled each count their Driver copies once"); var s = await stored(); ok(s.favorites.join() === "origin-2,origin-1", "persisted curated order " + JSON.stringify(s.favorites)); ok(!JSON.stringify(wsLog).includes("origin-"), "client never sent a durable key"); @@ -328,7 +337,7 @@ test("mobile stars are touch-sized, accessible and synchronize both representati } }); -test("session row metadata, age and actions have dedicated non-overlapping layout", async function () { +test("session row age and actions share one stable trailing slot", async function () { await freshWorld(); var sidebar = document.getElementById("sidebar"); var previousWidth = sidebar.style.width; @@ -338,13 +347,22 @@ test("session row metadata, age and actions have dedicated non-overlapping layou var linkedWork = [{ url: "https://github.com/clay/clay/pull/570", repository: "clay/clay", kind: "pr", number: 570, title: "Keep compact session actions clear of metadata", state: "open", + }, { + url: "https://github.com/clay/clay/issues/571", repository: "clay/clay", kind: "issue", number: 571, + title: "Preserve every linked work action", state: "open", + }]; + var linearWork = [{ + provider: "linear", url: "https://linear.app/clay/issue/CLAY-123", identifier: "CLAY-123", + title: "A deliberately long Linear issue title for narrow sidebar verification", stateName: "In Progress", }]; SESSIONS[0].title = "A very long ordinary session title that must truncate"; SESSIONS[0].lastActivity = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); SESSIONS[0].githubLinks = linkedWork; + SESSIONS[0].linearLinks = linearWork; SESSIONS[2].title = "A very long Driver session title with Workers"; SESSIONS[2].lastActivity = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); SESSIONS[2].githubLinks = linkedWork; + SESSIONS[2].linearLinks = linearWork; await renderAll(); drag(rowIn("unfiled", 1), section("favorites")); drag(rowIn("unfiled", 3), section("favorites")); @@ -352,6 +370,7 @@ test("session row metadata, age and actions have dedicated non-overlapping layou try { [280, 320].forEach(function (width) { + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); sidebar.style.width = width + "px"; sidebar.style.minWidth = width + "px"; [1, 3].forEach(function (id) { @@ -359,28 +378,85 @@ test("session row metadata, age and actions have dedicated non-overlapping layou var age = $(".session-item-age", sessionRow); var star = $(".session-folder-star-btn", sessionRow); var remove = $(".session-close-btn", sessionRow); + var trailing = $(".session-row-trailing", sessionRow); var metadata = $(".session-work-column", sessionRow); + var links = $(".session-row-github-links", sessionRow); + var linearId = $(".linear-work-id", links); + var github = $(".session-github-link:not(.session-linear-link)", links); + var more = $(".github-work-more", links); + age.getAnimations().forEach(function (animation) { animation.finish(); }); + star.getAnimations().forEach(function (animation) { animation.finish(); }); + remove.getAnimations().forEach(function (animation) { animation.finish(); }); ok(age && /d ago$/.test(age.textContent), width + "/" + id + ": realistic relative age is rendered: " + (age && age.textContent)); ok(metadata && metadata.getBoundingClientRect().right <= age.getBoundingClientRect().left + 0.5, width + "/" + id + ": title and linked-work metadata stop before age"); - ok(!boxesIntersect(age, star), width + "/" + id + ": age does not intersect the favorite star"); + ok(sessionRow.getBoundingClientRect().height <= 47 && links.getBoundingClientRect().height <= 19, width + "/" + id + ": linked work stays on one compact desktop line"); + ok(linearId && linearId.textContent === "CLAY-123" && linearId.getBoundingClientRect().right <= links.getBoundingClientRect().right + 0.5, width + "/" + id + ": full Linear identifier remains visible"); + ok(github && /#57[01]/.test(github.textContent) && more && /\+1/.test(more.textContent), width + "/" + id + ": GitHub number and overflow action remain available"); + ok(/CLAY-123/.test($(".session-linear-link", links).getAttribute("aria-label")) && /deliberately long/.test($(".session-linear-link", links).title), width + "/" + id + ": full Linear context remains accessible"); + var restingTitleWidth = metadata.getBoundingClientRect().width; + var trailingRect = trailing.getBoundingClientRect(); + var actionRect = $(".session-row-actions", trailing).getBoundingClientRect(); + var ageRect = age.getBoundingClientRect(); + ok(getComputedStyle(age).opacity !== "0" && getComputedStyle(star).opacity === "0" && getComputedStyle(remove).opacity === "0", width + "/" + id + ": age is visible and actions are hidden at rest"); + ok(trailingRect.width >= Math.max(ageRect.width, actionRect.width) - 0.5 && trailingRect.width < ageRect.width + actionRect.width - 0.5, width + "/" + id + ": one slot uses the wider state instead of summing both widths"); ok(!$(".session-folder-move-btn", sessionRow), width + "/" + id + ": no row-level Move button"); ok(!boxesIntersect(star, remove), width + "/" + id + ": star and delete controls have separate slots"); star.focus(); age.getAnimations().forEach(function (animation) { animation.finish(); }); - ok(getComputedStyle(age).opacity === "0", width + "/" + id + ": focusing the action cluster hides age without moving controls over it"); + star.getAnimations().forEach(function (animation) { animation.finish(); }); + remove.getAnimations().forEach(function (animation) { animation.finish(); }); + ok(getComputedStyle(age).opacity === "0" && Number(getComputedStyle(star).opacity) > 0 && Number(getComputedStyle(remove).opacity) > 0, width + "/" + id + ": keyboard focus replaces age with both actions"); + ok(Math.abs(metadata.getBoundingClientRect().width - restingTitleWidth) < 0.5 && Math.abs(trailing.getBoundingClientRect().width - trailingRect.width) < 0.5, width + "/" + id + ": focus swap does not shift title or trailing geometry"); ok(star.getBoundingClientRect().right <= sessionRow.getBoundingClientRect().right + 0.5 && remove.getBoundingClientRect().right <= sessionRow.getBoundingClientRect().right + 0.5, width + "/" + id + ": action cluster stays inside the row"); }); var unstarred = rowIn("unfiled", 2); var unstarredAge = $(".session-item-age", unstarred); var unstarredStar = $(".session-folder-star-btn", unstarred); unstarredStar.focus(); - ok(!boxesIntersect(unstarredAge, unstarredStar), width + ": focused unstarred row reserves the same age/action boundary"); + unstarredAge.getAnimations().forEach(function (animation) { animation.finish(); }); + unstarredStar.getAnimations().forEach(function (animation) { animation.finish(); }); + ok(getComputedStyle(unstarredAge).opacity === "0" && Number(getComputedStyle(unstarredStar).opacity) > 0, width + ": focused unstarred row uses the same replacement slot"); }); + var beforeActions = wsLog.length; + var targetStar = $(".session-folder-star-btn", rowIn("favorites", 1)); + click(targetStar); await sync(); + var actionMessages = wsLog.slice(beforeActions); + ok(actionMessages.some(function (msg) { return msg.type === "session_folders_op" && msg.op && msg.op.op === "set_favorite"; }), "favorite action keeps its exact folder operation"); + ok(!actionMessages.some(function (msg) { return msg.type === "switch_session"; }), "favorite action does not navigate its row"); + var beforeDelete = wsLog.length; + var deleteAction = $(".session-close-btn", rowIn("unfiled", 2)); + click(deleteAction); + var confirmDelete = $(".confirm-delete"); + if (confirmDelete) click(confirmDelete); + else click(deleteAction); + var deleteMessages = wsLog.slice(beforeDelete); + ok(deleteMessages.some(function (msg) { return msg.type === "delete_session" && msg.id === 2; }), "delete action keeps its exact two-click delete request"); + ok(!deleteMessages.some(function (msg) { return msg.type === "switch_session"; }), "delete action does not navigate its row"); + + var mobileHost = document.createElement("div"); + mobileHost.style.cssText = "position:fixed;left:0;top:0;width:320px"; + document.body.appendChild(mobileHost); + renderMobileSessionsInto(mobileHost); + var mobileStar = $(".mobile-session-star", rowIn("unfiled", 2, mobileHost)); + var mobileRect = mobileStar.getBoundingClientRect(); + var beforeMobile = wsLog.length; + click(mobileStar); await sync(); + var mobileMessages = wsLog.slice(beforeMobile); + ok(mobileRect.width >= 40 && mobileRect.height >= 40 && getComputedStyle(mobileStar).opacity === "1", "mobile keeps an always-visible 40px favorite target"); + ok(mobileMessages.some(function (msg) { return msg.type === "session_folders_op"; }) && !mobileMessages.some(function (msg) { return msg.type === "switch_session"; }), "mobile favorite action remains usable without row navigation"); + mobileHost.remove(); + + store.set({ permissions: { sessionDelete: false } }); + renderSessionList(null); await sync(); + ok(!$(".session-close-btn", rowIn("unfiled", 1)) && $(".session-row-trailing", rowIn("unfiled", 1)), "delete permission gating removes only the delete action, not the trailing age slot"); + store.set({ permissions: null }); } finally { sidebar.style.width = previousWidth; sidebar.style.minWidth = previousMinWidth; Object.assign(SESSIONS[0], previousAlpha); Object.assign(SESSIONS[2], previousDriver); + delete SESSIONS[0].linearLinks; + delete SESSIONS[2].linearLinks; await renderAll(); } }); @@ -1180,6 +1256,12 @@ test("toolbar search: replaces the controls in the same footprint; query, result var outbox = await rpc("/rpc", { socket: "u1", msg: { type: "session_folders_op", slug: "proj", op: { op: "set_collapsed", containerKey: "favorites", collapsed: true } } }); deliver(outbox, "u1-a"); await sync(); ok(document.activeElement === searchInput() && searchInput().selectionStart === 1, "survives a server update"); + typeInto(searchInput(), "Worker"); + handleSearchResults({ query: "Worker", results: [{ id: 4 }] }); await sync(); + ok(unit(3) && row(4) && !row(5), "a Worker result retains its Driver hierarchy without retaining unmatched sibling generations"); + ok($(".session-search-count").textContent === "1" && $(".session-search-count").dataset.countDetail === "1 Driver · 1 Split Worker", "search headline counts the contextual Driver once and exposes the matching Worker count"); + ok($(".session-folder-count", section("unfiled")).textContent === "1" && $(".session-folder-count", section("unfiled")).dataset.countDetail === "1 Driver · 1 Split Worker", "filtered folder count follows the visible retained hierarchy"); + ok($(".session-date-group-count", section("unfiled")).textContent === "1", "filtered date group counts the retained Driver root once"); // clear text: stays open, focused, list restored click($("[aria-label='Clear search text']")); await sync(); ok(searchInput() && searchInput().value === "" && document.activeElement === searchInput() && unit(2) && unit(3), "Clear empties the text, keeps search open and focused, restores the list"); @@ -1215,6 +1297,88 @@ test("toolbar search: replaces the controls in the same footprint; query, result keydown(searchInput(), "Escape"); await sync(); }); +test("Worker hierarchy stays user-controlled and Worker clicks retain Driver context", async function () { + await freshWorld(); + store.set({ + activeSessionId: 5, + activeProjectSlug: "proj", + sessionActivatedProjectSlug: "proj", + sessionListProjectSlug: "proj", + splitGroupsProjectSlug: "proj", + wsPath: "/p/proj/ws", + socketPath: "/p/proj/ws", + splitGroups: [{ id: "current-pair", members: [3, 5], pair: { version: 2, driverId: 3, workerIds: [5] } }], + }); + SESSIONS[4].active = true; + await renderAll(); + var toggle = $(".session-driver-toggle", unit(3)); + var children = $(".session-worker-children", unit(3)); + ok(toggle.getAttribute("aria-expanded") === "false" && children.hidden, "active/current Worker does not implicitly expand its Driver"); + + click(magnifier()); await sync(); + typeInto(searchInput(), "Worker"); + handleSearchResults({ query: "Worker", results: [{ id: 4 }] }); await sync(); + toggle = $(".session-driver-toggle", unit(3)); + children = $(".session-worker-children", unit(3)); + ok(toggle.getAttribute("aria-expanded") === "false" && children.hidden, "search match keeps the hierarchy collapsed"); + click(toggle); + ok(toggle.getAttribute("aria-expanded") === "true" && !children.hidden, "explicit expansion opens matching Workers"); + click($('[aria-label="Clear search text"]')); await sync(); + SESSIONS.push({ id: 7, title: "Worker three", lastActivity: 1007, createdAt: 7, vendor: "codex", sessionRole: "worker", parentSessionId: 3, parentAvailable: true, workerGeneration: 3 }); + await renderAll(); + toggle = $(".session-driver-toggle", unit(3)); + children = $(".session-worker-children", unit(3)); + ok(toggle.getAttribute("aria-expanded") === "true" && !children.hidden && row(7), "new Worker and list refresh preserve explicit expansion"); + store.set({ connected: false }); + store.set({ connected: true }); + await renderAll(); + ok($(".session-driver-toggle", unit(3)).getAttribute("aria-expanded") === "true", "reconnect-style rerender preserves explicit expansion"); + click($(".session-driver-toggle", unit(3))); + await renderAll(); + ok($(".session-worker-children", unit(3)).hidden, "explicit collapse survives active/current Worker rerender"); + + click($(".session-driver-toggle", unit(3))); + store.set({ splitPanes: null }); + wsLog.length = 0; + click(row(4)); + var historicalSplit = store.get("splitPanes"); + ok(historicalSplit && historicalSplit.groupId === null && historicalSplit.panes.map(function (pane) { return pane.sessionId; }).join() === "3,4", "historical Worker opens beside its Driver without changing group membership"); + ok(wsLog.some(function (msg) { return msg.type === "switch_session" && msg.id === 3; }), "historical Worker navigation anchors the current session to its Driver"); + + store.set({ splitPanes: null }); + click(starIn("unfiled", 3)); await sync(); + var favoriteWorker = rowIn("favorites", 4); + ok(favoriteWorker, "Favorites retains the expanded Driver hierarchy and its Workers"); + click(favoriteWorker); + var favoriteSplit = store.get("splitPanes"); + ok(favoriteSplit && favoriteSplit.panes.map(function (pane) { return pane.sessionId; }).join() === "3,4", "Favorites Worker click keeps the exact Worker beside its Driver"); + + store.set({ splitPanes: null }); + click(row(5)); + var currentSplit = store.get("splitPanes"); + ok(currentSplit && currentSplit.groupId === "current-pair" && currentSplit.panes.map(function (pane) { return pane.sessionId; }).join() === "3,5", "current Worker reopens its configured Driver pair"); + + store.set({ splitPanes: null }); + var mobile = await mobileFixtureFrame("worker-hierarchy-mobile", 320, 640); + renderMobileSessionsInto(mobile.host); + var mobileToggle = $(".mobile-driver-toggle", mobile.host); + ok(mobileToggle.getAttribute("aria-expanded") === "false", "mobile hierarchy also defaults collapsed"); + click(mobileToggle); + var mobileHistorical = $('.mobile-session-item[data-session-id="4"]', mobile.host); + click(mobileHistorical); + var mobileSplit = store.get("splitPanes"); + ok(mobileSplit && mobileSplit.panes.map(function (pane) { return pane.sessionId; }).join() === "3,4", "mobile Worker click keeps the exact historical Worker beside its Driver"); + mobile.frame.remove(); + + store.set({ splitPanes: null }); + SESSIONS.push({ id: 8, title: "Unavailable Worker", lastActivity: 1008, createdAt: 8, vendor: "codex", sessionRole: "worker", parentSessionId: null, parentAvailable: false, workerGeneration: 1 }); + await renderAll(); + click($(".session-orphan-toggle")); + wsLog.length = 0; + click(row(8)); + ok(store.get("splitPanes") === null && wsLog.some(function (msg) { return msg.type === "switch_session" && msg.id === 8; }), "unavailable Driver falls back safely without inventing a pair"); +}); + test("new folder draft: a temporary folder header in the list, never a real folder until the ack", async function () { var f = await folderFixture(); var before = wsLog.length; diff --git a/test/fixtures/sidebar-density.html b/test/fixtures/sidebar-density.html new file mode 100644 index 000000000..269552e78 --- /dev/null +++ b/test/fixtures/sidebar-density.html @@ -0,0 +1,102 @@ + + + + + + Sidebar density fixture + + + + +
+

Desktop · 280px

+

Desktop · 320px

+

Mobile · 320/390px viewport

+
+
+ + + + diff --git a/test/session-provenance.test.js b/test/session-provenance.test.js index 85f85a287..68a2bef95 100644 --- a/test/session-provenance.test.js +++ b/test/session-provenance.test.js @@ -291,14 +291,14 @@ test("client hierarchy groups generations and quarantines orphaned Workers", asy assert.deepEqual(tree.orphans.map(function (worker) { return worker.id; }), [4]); }); -test("project hierarchy defaults open for active, current, and searched Workers", async function () { +test("project hierarchy stays collapsed until explicitly expanded", async function () { var url = pathToFileURL(path.join(__dirname, "../lib/public/modules/sidebar-session-hierarchy.js")).href; var module = await import(url + "?provenance-test=" + Date.now()); var idle = [{ id: 2, active: false }]; assert.equal(module.defaultHierarchyExpanded(idle, null, new Set()), false); - assert.equal(module.defaultHierarchyExpanded([{ id: 2, active: true }], null, new Set()), true); - assert.equal(module.defaultHierarchyExpanded(idle, null, new Set([2])), true); - assert.equal(module.defaultHierarchyExpanded(idle, new Set([2]), new Set()), true); + assert.equal(module.defaultHierarchyExpanded([{ id: 2, active: true }], null, new Set()), false); + assert.equal(module.defaultHierarchyExpanded(idle, null, new Set([2])), false); + assert.equal(module.defaultHierarchyExpanded(idle, new Set([2]), new Set()), false); var originalDocument = global.document; global.document = { createElement: function () { return hierarchyElement(); } }; @@ -313,13 +313,22 @@ test("project hierarchy defaults open for active, current, and searched Workers" var row = header.children[1]; var children = tree.children[1]; assert.notEqual(toggle, row); - assert.equal(children.hidden, false); - toggle.click(); - assert.equal(toggle.getAttribute("aria-expanded"), "false"); assert.equal(children.hidden, true); toggle.click(); assert.equal(toggle.getAttribute("aria-expanded"), "true"); assert.equal(children.hidden, false); + var refreshed = module.renderDesktopDriverHierarchy({ + driver: { id: 1, title: "Driver" }, + workers: [{ id: 3, active: true }, { id: 2, active: false }], + }, function () { return hierarchyElement(); }, function () { rerenders++; }, new Set([3])); + assert.equal(refreshed.children[0].children[0].getAttribute("aria-expanded"), "true"); + assert.equal(refreshed.children[1].hidden, false); + refreshed.children[0].children[0].click(); + var collapsedAgain = module.renderDesktopDriverHierarchy({ + driver: { id: 1, title: "Driver" }, + workers: [{ id: 3, active: true }], + }, function () { return hierarchyElement(); }, function () { rerenders++; }, new Set([3])); + assert.equal(collapsedAgain.children[1].hidden, true); assert.equal(rerenders, 0); } finally { global.document = originalDocument; diff --git a/test/sidebar-density.test.js b/test/sidebar-density.test.js new file mode 100644 index 000000000..1001a0ec6 --- /dev/null +++ b/test/sidebar-density.test.js @@ -0,0 +1,266 @@ +var test = require('node:test'); +var assert = require('node:assert/strict'); +var fs = require('node:fs'); +var http = require('node:http'); +var path = require('node:path'); + +function loadPlaywright() { + try { return require('playwright'); } catch (error) { return null; } +} + +function serveRepository() { + var root = path.join(__dirname, '..'); + var server = http.createServer(function (request, response) { + var relative = decodeURIComponent(request.url.split('?')[0]).replace(/^\/+/, ''); + var file = path.resolve(root, relative || 'test/fixtures/sidebar-density.html'); + if (file.indexOf(root + path.sep) !== 0 || !fs.existsSync(file)) { response.writeHead(404); response.end('not found'); return; } + var extension = path.extname(file); + response.setHeader('Content-Type', extension === '.js' ? 'text/javascript' : extension === '.css' ? 'text/css' : 'text/html'); + response.end(fs.readFileSync(file)); + }); + return new Promise(function (resolve) { server.listen(0, '127.0.0.1', function () { resolve(server); }); }); +} + +test('linked work remains compact and complete at narrow desktop and mobile widths', async function (t) { + var playwright = loadPlaywright(); + if (!playwright) { t.skip('Playwright is not installed'); return; } + var browser; + try { browser = await playwright.chromium.launch(); } catch (error) { t.skip('Chromium is not available: ' + error.message); return; } + var server = await serveRepository(); + t.after(function () { server.close(); return browser.close(); }); + var page = await browser.newPage({ viewport: { width: 1100, height: 700 } }); + await page.goto('http://127.0.0.1:' + server.address().port + '/test/fixtures/sidebar-density.html'); + + var desktop = await page.evaluate(function () { + return ['desktop-280', 'desktop-280-long', 'desktop-320', 'desktop-320-long'].map(function (id) { + var panel = document.getElementById(id).closest('.fixture-panel'); + var row = document.querySelector('#' + id + ' [data-session-id]'); + var links = row.querySelector('.session-row-github-links'); + var linear = row.querySelector('.session-linear-link'); + var linearId = row.querySelector('.linear-work-id'); + var linearTitle = row.querySelector('.linear-work-title'); + var linearState = row.querySelector('.linear-work-state'); + var compactState = row.querySelector('.linear-work-state-compact'); + var github = row.querySelector('.session-github-link:not(.session-linear-link)'); + var more = row.querySelector('.github-work-more'); + var bounds = links.getBoundingClientRect(); + var linearBounds = linear.getBoundingClientRect(); + function within(element, outer) { + var rect = element.getBoundingClientRect(); + return !rect.width || rect.left >= outer.left - 0.5 && rect.right <= outer.right + 0.5 && rect.top >= outer.top - 0.5 && rect.bottom <= outer.bottom + 0.5; + } + return { + id: id, + panelWidth: panel.getBoundingClientRect().width, + rowHeight: row.getBoundingClientRect().height, + linkHeight: bounds.height, + linearId: linearId.textContent, + compactState: compactState.textContent, + githubText: github.textContent, + moreText: more.textContent, + textWithinPill: [linearId, linearTitle, linearState, compactState].every(function (element) { return within(element, linearBounds); }), + adjacentWithinLinks: within(github, bounds) && within(more, bounds), + noRowOverflow: row.scrollWidth <= row.clientWidth, + idTruncated: linearId.scrollWidth > linearId.clientWidth + 0.5, + titleTruncated: linearTitle.scrollWidth > linearTitle.clientWidth + 0.5, + stateTruncated: compactState.scrollWidth > compactState.clientWidth + 0.5, + idOverflow: getComputedStyle(linearId).textOverflow, + titleTextOverflow: getComputedStyle(linearTitle).textOverflow, + stateOverflow: getComputedStyle(compactState).textOverflow, + linearLabel: linear.getAttribute('aria-label'), + linearTitle: linear.title, + titleOverflow: getComputedStyle(row.querySelector('.session-item-text')).textOverflow, + }; + }); + }); + assert.deepEqual(desktop.map(function (entry) { return entry.panelWidth; }), [280, 280, 320, 320]); + desktop.forEach(function (entry) { + assert.ok(entry.rowHeight <= 47, 'desktop row remains at most 47px: ' + JSON.stringify(entry)); + assert.ok(entry.linkHeight <= 19, 'linked work uses one compact line: ' + JSON.stringify(entry)); + assert.match(entry.githubText, /#602/); + assert.equal(entry.moreText, '+1'); + assert.equal(entry.textWithinPill, true, 'Linear text stays inside its pill: ' + JSON.stringify(entry)); + assert.equal(entry.adjacentWithinLinks, true, 'adjacent actions stay inside linked-work row: ' + JSON.stringify(entry)); + assert.equal(entry.noRowOverflow, true, 'row has no horizontal overflow: ' + JSON.stringify(entry)); + assert.equal(entry.idOverflow, 'ellipsis'); + assert.equal(entry.titleTextOverflow, 'ellipsis'); + assert.equal(entry.stateOverflow, 'ellipsis'); + assert.equal(entry.titleOverflow, 'ellipsis'); + }); + desktop.filter(function (entry) { return entry.id.indexOf('-long') < 0; }).forEach(function (entry) { + assert.equal(entry.linearId, 'TLE-458'); + assert.equal(entry.idTruncated, false, 'ordinary Linear identifier remains complete: ' + JSON.stringify(entry)); + assert.match(entry.compactState, /^Deployed/); + assert.match(entry.linearLabel, /TLE-458.*Deployed to Staging/); + assert.match(entry.linearTitle, /deliberately long Linear issue title/); + }); + desktop.filter(function (entry) { return entry.id.indexOf('-long') >= 0; }).forEach(function (entry) { + assert.equal(entry.linearId, 'PLATFORMOPS-10428'); + assert.equal(entry.idTruncated, true, 'long Linear identifier ellipsizes before leaving its pill: ' + JSON.stringify(entry)); + assert.match(entry.linearLabel, /PLATFORMOPS-10428.*Waiting for Customer Validation/); + }); + var untouched = await page.evaluate(function () { + var header = document.querySelector('#header-github-links .session-github-links'); + var headerLink = header.querySelector('.session-github-link'); + var details = document.querySelector('#density-details .github-work-details'); + return { + headerFontSize: getComputedStyle(header).fontSize, + headerLineHeight: getComputedStyle(header).lineHeight, + headerGap: getComputedStyle(header).gap, + headerLinkGap: getComputedStyle(headerLink).gap, + headerLinkPaddingLeft: getComputedStyle(headerLink).paddingLeft, + headerFullState: header.querySelector('.linear-work-state-full').textContent, + headerCompactDisplay: getComputedStyle(header.querySelector('.linear-work-state-compact')).display, + detailsGap: getComputedStyle(details).gap, + detailsPaddingTop: getComputedStyle(details).paddingTop, + detailsPaddingBottom: getComputedStyle(details).paddingBottom, + }; + }); + assert.deepEqual(untouched, { + headerFontSize: '11px', headerLineHeight: '18px', headerGap: '8px', headerLinkGap: '5px', headerLinkPaddingLeft: '5px', + headerFullState: 'Deployed to Staging', headerCompactDisplay: 'none', + detailsGap: '8px 12px', detailsPaddingTop: '8px', detailsPaddingBottom: '8px', + }); + + var restingSlot = await page.evaluate(function () { + var row = document.querySelector('#desktop-280 [data-session-id]'); + var title = row.querySelector('.session-item-text'); + var slot = row.querySelector('.session-row-trailing'); + var age = slot.querySelector('.session-item-age'); + var actions = slot.querySelector('.session-row-actions'); + var star = slot.querySelector('.session-folder-star-btn'); + var remove = slot.querySelector('.session-close-btn'); + return { + titleWidth: title.getBoundingClientRect().width, + slotWidth: slot.getBoundingClientRect().width, + actionWidth: actions.getBoundingClientRect().width, + ageWidth: age.getBoundingClientRect().width, + ageOpacity: getComputedStyle(age).opacity, + starOpacity: getComputedStyle(star).opacity, + removeOpacity: getComputedStyle(remove).opacity, + }; + }); + assert.notEqual(restingSlot.ageOpacity, '0'); + assert.equal(restingSlot.starOpacity, '0'); + assert.equal(restingSlot.removeOpacity, '0'); + assert.ok(restingSlot.slotWidth >= Math.max(restingSlot.actionWidth, restingSlot.ageWidth) - 0.5); + assert.ok(restingSlot.slotWidth < restingSlot.actionWidth + restingSlot.ageWidth - 0.5); + + await page.locator('#desktop-280 [data-session-id]').hover(); + await page.waitForTimeout(180); + var hoverSlot = await page.evaluate(function () { + var row = document.querySelector('#desktop-280 [data-session-id]'); + return { + titleWidth: row.querySelector('.session-item-text').getBoundingClientRect().width, + slotWidth: row.querySelector('.session-row-trailing').getBoundingClientRect().width, + ageOpacity: getComputedStyle(row.querySelector('.session-item-age')).opacity, + starOpacity: getComputedStyle(row.querySelector('.session-folder-star-btn')).opacity, + removeOpacity: getComputedStyle(row.querySelector('.session-close-btn')).opacity, + }; + }); + assert.equal(hoverSlot.ageOpacity, '0'); + assert.ok(Number(hoverSlot.starOpacity) > 0); + assert.ok(Number(hoverSlot.removeOpacity) > 0); + assert.ok(Math.abs(hoverSlot.titleWidth - restingSlot.titleWidth) < 0.5); + assert.ok(Math.abs(hoverSlot.slotWidth - restingSlot.slotWidth) < 0.5); + + await page.locator('#desktop-280 .session-folder-star-btn').focus(); + await page.waitForTimeout(180); + var focusSlot = await page.evaluate(function () { + var row = document.querySelector('#desktop-280 [data-session-id]'); + return { + titleWidth: row.querySelector('.session-item-text').getBoundingClientRect().width, + slotWidth: row.querySelector('.session-row-trailing').getBoundingClientRect().width, + ageOpacity: getComputedStyle(row.querySelector('.session-item-age')).opacity, + starOpacity: getComputedStyle(row.querySelector('.session-folder-star-btn')).opacity, + }; + }); + assert.equal(focusSlot.ageOpacity, '0'); + assert.ok(Number(focusSlot.starOpacity) > 0); + assert.ok(Math.abs(focusSlot.titleWidth - restingSlot.titleWidth) < 0.5); + assert.ok(Math.abs(focusSlot.slotWidth - restingSlot.slotWidth) < 0.5); + await page.emulateMedia({ reducedMotion: 'reduce' }); + var reducedMotion = await page.evaluate(function () { + var row = document.querySelector('#desktop-280 [data-session-id]'); + return [row.querySelector('.session-item-age'), row.querySelector('.session-folder-star-btn'), row.querySelector('.session-close-btn')].map(function (element) { + return getComputedStyle(element).transitionDuration; + }); + }); + assert.deepEqual(reducedMotion, ['0s', '0s', '0s']); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + + await page.locator('#desktop-280 .github-work-more').click(); + await page.locator('#github-work-popover').waitFor(); + assert.equal(await page.locator('#desktop-280 .github-work-more').getAttribute('aria-expanded'), 'true'); + var popoverLinear = page.locator('#github-work-popover .session-linear-link').first(); + assert.match(await popoverLinear.getAttribute('aria-label'), /TLE-458.*deliberately long Linear issue title.*Deployed to Staging/); + assert.equal(await popoverLinear.locator('.linear-work-state-full').textContent(), 'Deployed to Staging'); + assert.equal(await popoverLinear.locator('.linear-work-state-full').evaluate(function (element) { return getComputedStyle(element).display; }), 'inline'); + assert.equal(await popoverLinear.locator('.linear-work-state-compact').evaluate(function (element) { return getComputedStyle(element).display; }), 'none'); + await page.locator('.fixture-label').first().click(); + assert.equal(await page.locator('#github-work-popover').count(), 0); + assert.equal(await page.locator('#desktop-280 .github-work-more').getAttribute('aria-expanded'), 'false'); + + var mobileResults = []; + for (var viewportWidth of [320, 390]) { + await page.setViewportSize({ width: viewportWidth, height: 700 }); + var mobile = await page.evaluate(function () { + var panel = document.querySelector('.mobile-fixture'); + return ['mobile-320', 'mobile-320-long'].map(function (id) { + var row = document.querySelector('#' + id + ' [data-session-id]'); + var links = row.querySelector('.session-row-github-links'); + var linear = row.querySelector('.session-linear-link'); + var linearBounds = linear.getBoundingClientRect(); + var targets = Array.prototype.slice.call(links.querySelectorAll('a, button')); + var text = Array.prototype.slice.call(linear.querySelectorAll('.linear-work-id, .linear-work-title, .linear-work-state, .linear-work-state-compact')); + var bounds = links.getBoundingClientRect(); + return { + id: id, + panelWidth: panel.getBoundingClientRect().width, + rowHeight: row.getBoundingClientRect().height, + linkHeight: bounds.height, + linearId: row.querySelector('.linear-work-id').textContent, + linearLabel: linear.getAttribute('aria-label'), + idTruncated: row.querySelector('.linear-work-id').scrollWidth > row.querySelector('.linear-work-id').clientWidth + 0.5, + githubText: row.querySelector('.session-github-link:not(.session-linear-link)').textContent, + moreText: row.querySelector('.github-work-more').textContent, + minTargetHeight: Math.min.apply(Math, targets.map(function (target) { return target.getBoundingClientRect().height; })), + targetsWithin: targets.every(function (target) { + var rect = target.getBoundingClientRect(); + return rect.left >= bounds.left - 0.5 && rect.right <= bounds.right + 0.5; + }), + textWithinPill: text.every(function (element) { + var rect = element.getBoundingClientRect(); + return !rect.width || rect.left >= linearBounds.left - 0.5 && rect.right <= linearBounds.right + 0.5; + }), + noHorizontalOverflow: row.scrollWidth <= row.clientWidth, + starHeight: row.querySelector('.mobile-session-star').getBoundingClientRect().height, + starOpacity: getComputedStyle(row.querySelector('.mobile-session-star')).opacity, + }; + }); + }); + mobile.forEach(function (entry) { entry.viewportWidth = viewportWidth; mobileResults.push(entry); }); + } + mobileResults.forEach(function (mobile) { + assert.equal(mobile.panelWidth, mobile.viewportWidth - 24); + assert.ok(mobile.rowHeight <= 70, 'mobile row remains bounded: ' + JSON.stringify(mobile)); + assert.ok(mobile.linkHeight <= 29, 'mobile linked work remains one line: ' + JSON.stringify(mobile)); + assert.match(mobile.githubText, /#602/); + assert.equal(mobile.moreText, '+1'); + assert.ok(mobile.minTargetHeight >= 28); + assert.equal(mobile.targetsWithin, true, 'mobile targets remain inside linked-work row: ' + JSON.stringify(mobile)); + assert.equal(mobile.textWithinPill, true, 'mobile Linear text remains inside pill: ' + JSON.stringify(mobile)); + assert.equal(mobile.noHorizontalOverflow, true); + assert.ok(mobile.starHeight >= 40); + assert.equal(mobile.starOpacity, '1'); + assert.match(mobile.linearLabel, /Linear issue title|Waiting for Customer Validation/); + }); + mobileResults.filter(function (entry) { return entry.id.indexOf('-long') < 0; }).forEach(function (entry) { + assert.equal(entry.linearId, 'TLE-458'); + assert.equal(entry.idTruncated, false, 'ordinary identifier remains complete on mobile: ' + JSON.stringify(entry)); + }); + mobileResults.filter(function (entry) { return entry.id.indexOf('-long') >= 0; }).forEach(function (entry) { + assert.equal(entry.linearId, 'PLATFORMOPS-10428'); + assert.equal(entry.idTruncated, true, 'long identifier remains bounded on mobile: ' + JSON.stringify(entry)); + }); +}); diff --git a/test/sidebar-session-counts.test.js b/test/sidebar-session-counts.test.js new file mode 100644 index 000000000..29d05f5e9 --- /dev/null +++ b/test/sidebar-session-counts.test.js @@ -0,0 +1,51 @@ +var test = require('node:test'); +var assert = require('node:assert/strict'); +var path = require('node:path'); +var url = require('node:url'); + +var modulePromise = import(url.pathToFileURL(path.join(__dirname, '../lib/public/modules/sidebar-session-counts.js')).href); +var dateGroupsPromise = import(url.pathToFileURL(path.join(__dirname, '../lib/public/modules/session-date-groups.js')).href); + +test('sidebar headline counts unique roots while Worker detail includes visible retained generations', async function () { + var counts = await modulePromise; + var driver = { id: 1, sessionRole: 'driver' }; + var workerOne = { id: 2, sessionRole: 'worker' }; + var workerTwo = { id: 3, sessionRole: 'worker' }; + var items = [ + { type: 'driver-hierarchy', root: { driver: driver, workers: [workerOne, workerTwo] } }, + { type: 'session', data: { id: 4 } }, + { type: 'orphan-workers', workers: [{ id: 5, sessionRole: 'worker' }] }, + { type: 'split-group', members: [{ id: 6 }, workerOne] }, + { type: 'loop', children: [{ id: 7 }, { id: 7 }] }, + ]; + assert.deepEqual(counts.summarizeSidebarItems(items, null), { roots: 4, workers: 3 }); + assert.equal(counts.sidebarCountLabel({ roots: 4, workers: 3 }, true), '4 Drivers · 3 Split Workers'); +}); + +test('hierarchy search counts the contextual Driver once and only rendered Worker matches', async function () { + var counts = await modulePromise; + var item = { type: 'driver-hierarchy', root: { + driver: { id: 10, sessionRole: 'driver' }, + workers: [{ id: 11, sessionRole: 'worker' }, { id: 12, sessionRole: 'worker' }], + } }; + assert.deepEqual(counts.summarizeSidebarItems([item], new Set([11])), { roots: 1, workers: 1 }); + assert.deepEqual(counts.summarizeSidebarItems([item], new Set([10])), { roots: 1, workers: 2 }); + assert.deepEqual(counts.summarizeSidebarUnits([{ item: item }, { item: item }], null), { roots: 1, workers: 2 }); +}); + +test('display counts stay semantic while date Clear keeps its independent session-id scope', async function () { + var counts = await modulePromise; + var dateGroups = await dateGroupsPromise; + var units = [{ item: { + type: 'orphan-workers', + workers: [{ id: 20, sessionRole: 'worker' }, { id: 21, sessionRole: 'worker' }], + } }, { item: { + type: 'split-group', + members: [{ id: 22, sessionRole: 'driver' }, { id: 23, sessionRole: 'worker' }], + } }, { item: { + type: 'loop', + children: [{ id: 24, sessionRole: 'driver' }, { id: 25, sessionRole: 'driver' }], + } }]; + assert.deepEqual(counts.summarizeSidebarUnits(units, null), { roots: 3, workers: 3 }); + assert.deepEqual(dateGroups.groupRootSessionIds(units), [20, 21, 22, 23, 24, 25]); +}); diff --git a/test/sidebar-session-list-ui.test.js b/test/sidebar-session-list-ui.test.js index c0800c81a..cd1bc6274 100644 --- a/test/sidebar-session-list-ui.test.js +++ b/test/sidebar-session-list-ui.test.js @@ -15,6 +15,8 @@ test("session rows separate agent identity, title, and recency", function () { assert.match(renderSource, /vendorIcon\.className = "session-vendor-icon session-vendor-mark"/); assert.ok(renderSource.indexOf("el.appendChild(vendorIcon)") < renderSource.indexOf("el.appendChild(textSpan)")); assert.match(renderSource, /age\.className = "session-item-age"/); + assert.match(renderSource, /trailing\.className = "session-row-trailing"/); + assert.ok(renderSource.indexOf("trailing.appendChild(age)") < renderSource.indexOf("trailing.appendChild(actions)")); assert.match(css, /\.session-vendor-mark\s*\{[^}]*width:\s*16px[^}]*background:\s*transparent[^}]*box-shadow:\s*none/s); assert.match(css, /\.session-item\.active \.session-vendor-mark\s*\{[^}]*opacity:\s*0\.88/s); assert.match(css, /\.session-item-age\s*\{[^}]*font-size:\s*9\.5px/s); diff --git a/test/split-view.test.js b/test/split-view.test.js index 82f360db7..27428e717 100644 --- a/test/split-view.test.js +++ b/test/split-view.test.js @@ -83,6 +83,25 @@ test("targeted Worker close payload carries stable Driver, group, Worker, and ge assert.equal(helpers.splitWorkerCloseRequest(group, [{ id: 33, workerGeneration: null }], "project-a", 33, "missing"), null); }); +test("Worker split navigation keeps the exact Worker with its accessible Driver", async function () { + var helpers = await loadSplitGroupHelpers(); + var driver = { id: 11, sessionRole: "driver" }; + var current = { id: 22, sessionRole: "worker", parentAvailable: true, parentSessionId: 11, workerGeneration: 3 }; + var historical = { id: 33, sessionRole: "worker", parentAvailable: true, parentSessionId: 11, workerGeneration: 2 }; + var missing = { id: 44, sessionRole: "worker", parentAvailable: false, parentSessionId: null, workerGeneration: 1 }; + var group = { id: "current-pair", members: [11, 22], pair: { version: 2, driverId: 11, workerIds: [22] } }; + var currentTarget = helpers.resolveWorkerSplitTarget(current, [driver, current, historical, missing], [group]); + assert.equal(currentTarget.driver, driver); + assert.equal(currentTarget.worker, current); + assert.equal(currentTarget.group, group); + var historicalTarget = helpers.resolveWorkerSplitTarget(historical, [driver, current, historical, missing], [group]); + assert.equal(historicalTarget.driver, driver); + assert.equal(historicalTarget.worker, historical); + assert.equal(historicalTarget.group, null); + assert.equal(helpers.resolveWorkerSplitTarget(missing, [driver, missing], [group]), null); + assert.equal(helpers.resolveWorkerSplitTarget(historical, [historical], [group]), null); +}); + test("split session selection is scoped to the hydrated project socket", async function () { var helpers = await loadProjectActivationHelpers(); var socket = { readyState: 1 };