From 475af5811398dee4ba746575d7d7e6e0ec206442 Mon Sep 17 00:00:00 2001 From: Bryan Ehrlich Date: Tue, 6 Oct 2026 19:44:10 -0400 Subject: [PATCH 1/7] Revert the one-line session redesign back to the v0.148.2 session UI --- test/web/orgmode.mjs | 81 +++----- web/fixtures/session-inventory.js | 14 +- web/index.html | 9 +- web/src/helpers.js | 7 +- web/src/pty.js | 5 +- web/src/render.js | 301 ++++++++++++++++++----------- web/src/session-inventory.js | 77 +------- web/src/terminal.js | 8 +- web/style.css | 221 +++++++++++++-------- web/test/session-inventory.test.js | 85 +------- web/test/session-name.test.js | 11 +- 11 files changed, 384 insertions(+), 435 deletions(-) diff --git a/test/web/orgmode.mjs b/test/web/orgmode.mjs index 2953da0b..4035380a 100644 --- a/test/web/orgmode.mjs +++ b/test/web/orgmode.mjs @@ -568,22 +568,15 @@ try { } try { - // Rename lives in the open session's details, reached from its title. - record('carol: sidebar rows carry no controls', - await p.locator('#session-tabs .session-tab button').count() === 0); - await p.focus('#header-title'); - await p.keyboard.press('Enter'); - await p.locator('#detail-egg-rename').waitFor({ state: 'visible', timeout: 5000 }); - record('carol: the keyboard opens session details from the title and lands inside them', - await p.evaluate(() => document.getElementById('detail-dialog').contains(document.activeElement))); - await p.click('#detail-egg-rename'); - await p.locator('#detail-dialog .session-name-input').fill('support-night-review'); - await p.locator('#detail-dialog .session-name-input').press('Enter'); + const tab = p.locator(`#session-tabs .session-tab[data-sid="${carolSessionID}"]`); + await tab.hover(); + await tab.locator('.session-rename-btn').click(); + await tab.locator('.session-name-input').fill('support-night-review'); + await tab.locator('.session-name-input').press('Enter'); await p.waitForFunction((sessionID) => { const candidate = document.querySelector(`#session-tabs .session-tab[data-sid="${sessionID}"] .tab-label`); return candidate && candidate.textContent === 'support-night-review'; }, carolSessionID, { timeout: 10000 }); - await p.click('#detail-backdrop', { position: { x: 8, y: 8 } }); await p.reload({ waitUntil: 'domcontentloaded' }); await p.waitForSelector(`#session-tabs .session-tab[data-sid="${carolSessionID}"] .tab-label`, { timeout: 20000 }); const persisted = await p.locator(`#session-tabs .session-tab[data-sid="${carolSessionID}"] .tab-label`).textContent(); @@ -649,13 +642,11 @@ try { const firstBox = await rows.nth(firstIndex).boundingBox(); const secondBox = await rows.nth(secondIndex).boundingBox(); if (!firstBox || !secondBox) throw new Error('copy fixture rows have no layout box'); - // Copy is not a resting control: it appears only for a selection. - if (await p.locator('#terminal-copy-btn').isVisible()) throw new Error('copy was visible before any selection'); await p.mouse.move(firstBox.x + 2, firstBox.y + firstBox.height / 2); await p.mouse.down(); await p.mouse.move(secondBox.x + 112, secondBox.y + secondBox.height / 2, { steps: 8 }); await p.mouse.up(); - await p.waitForSelector('#terminal-copy-btn', { state: 'visible', timeout: 5000 }); + await p.waitForFunction(() => !document.getElementById('terminal-copy-btn').disabled, null, { timeout: 5000 }); await p.click('#terminal-copy-btn'); const copied = await p.evaluate(() => window.__wingthingCopiedText); record('carol: clean copy preserves indentation and removes terminal line padding', @@ -708,20 +699,13 @@ try { try { await alice.page.goto(BASE + '/app/', { waitUntil: 'domcontentloaded' }); await alice.page.waitForSelector(`#session-tabs .session-tab[data-sid="${carolSessionID}"]`, { timeout: 20000 }); - const tabButtons = await alice.page.locator('#session-tabs .session-tab button').count(); + const adminRename = await alice.page.locator(`#session-tabs .session-tab[data-sid="${carolSessionID}"] .session-rename-btn`).count(); + record('admin: another user session does not expose rename', adminRename === 0, `rename buttons=${adminRename}`); const foreignCard = alice.page.locator(`#sessions-list .egg-box[data-sid="${carolSessionID}"]`); await foreignCard.waitFor({ state: 'visible', timeout: 20000 }); - const cardButtons = await foreignCard.locator('button').count(); - await foreignCard.locator('.inventory-details').click(); - await alice.page.waitForSelector('#detail-egg-delete', { state: 'visible', timeout: 10000 }); - const adminRename = await alice.page.locator('#detail-egg-rename, #detail-dialog .session-fork-btn').count(); - record('admin: another user session does not expose rename', adminRename === 0 && tabButtons === 0, - `rename/fork actions=${adminRename} sidebar buttons=${tabButtons}`); - record('admin: details preserve allowed inspection and stop while rename remains owner-only', - cardButtons === 1 && await foreignCard.locator('.inventory-details').count() === 1 && - await alice.page.locator('#detail-egg-delete').count() === 1 && - (await alice.page.locator('#detail-dialog').textContent()).includes('/opt/wingthing/support')); - await alice.page.click('#detail-backdrop', { position: { x: 8, y: 8 } }); + record('admin: inventory preserves allowed inspection and stop while rename remains owner-only', + await foreignCard.locator('.inventory-details, .inventory-stop').count() === 2 && + await foreignCard.locator('.inventory-rename').count() === 0); } catch (e) { record('admin: another user session does not expose rename', false, String(e).slice(0, 200)); } @@ -739,27 +723,28 @@ try { try { const p = carol.page; await p.click('#home-btn'); - const card = p.locator(`#sessions-list .egg-box[data-sid="${carolSessionID}"]`); - await card.waitFor({ state: 'visible', timeout: 20000 }); - const cards = await p.locator('#sessions-list .egg-box').count(); - // A short inventory shows no search, and a single project needs no header. - const searchHidden = cards >= 8 || !(await p.locator('#session-inventory-search').isVisible()); - const headers = await p.locator('#sessions-list .inventory-group-header').count(); - record('carol: a named owned session stays inspectable under its project in a calm inventory', - searchHidden && headers === 0 && - await card.locator('button').count() === 1 && await card.locator('.inventory-details').count() === 1 && - (await card.getAttribute('aria-label')).includes('/opt/wingthing/support') && - (await card.locator('.egg-label').textContent()) === 'support-night-review', - `cards=${cards} search_hidden=${searchHidden} headers=${headers}`); - await p.locator('#sessions-list .egg-box').first().focus(); - for (let i = 0; i < cards && !(await p.evaluate((id) => document.activeElement?.dataset.sid === id, carolSessionID)); i++) { - await p.keyboard.press('ArrowDown'); - } + await p.waitForSelector('#session-inventory-search', { state: 'visible', timeout: 20000 }); + await p.fill('#session-inventory-search', 'support-night-review'); + await p.waitForFunction((id) => { + const rows = Array.from(document.querySelectorAll('#sessions-list .egg-box')); + return rows.length === 1 && rows[0].dataset.sid === id; + }, carolSessionID, { timeout: 10000 }); + // One provider means its filter cannot narrow anything, so it stays hidden. + const agentFilterHidden = !(await p.locator('#session-inventory-agent').isVisible()); + const card = p.locator('#sessions-list .egg-box').first(); + const group = p.locator('#sessions-list .inventory-project-group').first(); + record('carol: search keeps a named owned session inspectable under its project', + await p.inputValue('#session-inventory-search') === 'support-night-review' && agentFilterHidden && + await card.locator('.inventory-attach, .inventory-details, .inventory-rename, .inventory-stop').count() === 4 && + (await group.locator('.inventory-group-header').textContent()).includes('/opt/wingthing/support')); + await p.locator('#session-inventory-search').focus(); + await p.keyboard.press('ArrowDown'); record('carol: keyboard inventory navigation focuses the exact matching session', await p.evaluate((id) => document.activeElement?.dataset.sid === id, carolSessionID)); - await shot(p, 'carol-inventory'); + await shot(p, 'carol-filtered-inventory'); + await p.click('#session-inventory-clear'); } catch (e) { - record('carol: calm inventory and keyboard navigation', false, String(e).slice(0, 200)); + record('carol: searchable inventory and keyboard navigation', false, String(e).slice(0, 200)); } // ---------- Carol (support member), mobile ---------- @@ -769,12 +754,6 @@ try { try { await waitWing(p); record('carol-mobile: dashboard renders with shared wing', true); - await p.waitForSelector('#sessions-list .egg-box', { state: 'visible', timeout: 20000 }); - const boxes = await p.locator('#sessions-list .egg-box').evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().toJSON())); - const previews = await p.locator('#sessions-list .egg-preview').count(); - record('carol-mobile: session cards are single full-width lines above the fold', - previews === 0 && boxes.every((box) => box.height >= 44 && box.height <= 50) && boxes[0].bottom <= 844, - JSON.stringify(boxes.map((box) => [Math.round(box.top), Math.round(box.height)]))); } catch (e) { record('carol-mobile: dashboard renders with shared wing', false, String(e).slice(0, 200)); } diff --git a/web/fixtures/session-inventory.js b/web/fixtures/session-inventory.js index ba8b2951..631b9119 100644 --- a/web/fixtures/session-inventory.js +++ b/web/fixtures/session-inventory.js @@ -11,12 +11,12 @@ S.wingsData = [ { wing_id: 'fixture-offline', wing_label: 'Travel laptop', hostname: 'travel-laptop', online: false, platform: 'darwin', agents: ['claude'], projects: [], user_id: 'fixture-owner' } ]; S.sessionsData = [ - { id: 'fixture-parent', wing_id: 'fixture-mac', name: 'weekend-coordinator', agent: 'claude', cwd: '/Users/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_id: 'fixture-logical-parent', conversation_role: 'parent', lifecycle: { state: 'idle', status: 'idle', state_source: 'claude_hook', process_alive: true }, lifecycle_seen_at: Date.now() }, - { id: 'fixture-child', wing_id: 'fixture-wsl', name: 'remote-reconnect-review', agent: 'claude', cwd: '/home/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'working', status: 'working', state_source: 'claude_hook' } }, + { id: 'fixture-parent', wing_id: 'fixture-mac', name: 'weekend-coordinator', agent: 'claude', cwd: '/Users/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_id: 'fixture-logical-parent', conversation_role: 'parent', lifecycle: { state: 'idle', state_source: 'claude_hook', process_alive: true }, lifecycle_seen_at: Date.now() }, + { id: 'fixture-child', wing_id: 'fixture-wsl', name: 'remote-reconnect-review', agent: 'claude', cwd: '/home/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'working', state_source: 'claude_hook' } }, { id: 'fixture-child', wing_id: 'fixture-mac', name: 'local-review-same-provider-id', agent: 'claude', cwd: '/Users/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', needs_attention: true }, - { id: 'fixture-input', wing_id: 'fixture-mac', name: 'ui-accessibility-check', agent: 'claude', cwd: '/Users/example/Projects/wingthing/web', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'needs_input', status: 'blocked', state_source: 'claude_hook' } }, + { id: 'fixture-input', wing_id: 'fixture-mac', name: 'ui-accessibility-check', agent: 'claude', cwd: '/Users/example/Projects/wingthing/web', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'needs_input', state_source: 'claude_hook' } }, { id: 'fixture-unknown', wing_id: 'fixture-wsl', name: 'provider-compatibility', agent: 'codex', cwd: '/home/example/Projects/provider-fixtures', user_id: 'fixture-owner', swept: true, status: 'detached' }, - { id: 'fixture-disconnected', wing_id: 'fixture-offline', name: 'docs-review', agent: 'claude', cwd: '/Users/example/Projects/wingthing/docs', user_id: 'fixture-owner', swept: false, status: 'detached', lifecycle: { state: 'working', status: 'working', state_source: 'claude_hook' } } + { id: 'fixture-disconnected', wing_id: 'fixture-offline', name: 'docs-review', agent: 'claude', cwd: '/Users/example/Projects/wingthing/docs', user_id: 'fixture-owner', swept: false, status: 'detached', lifecycle: { state: 'working', state_source: 'claude_hook' } } ]; function render() { renderSidebar(); renderDashboard(); } initParentDot(); @@ -25,8 +25,6 @@ render(); document.getElementById('fixture-parent-attention').addEventListener('click', function() { var parent = S.sessionsData.find(function(session) { return session.conversation_id === 'fixture-logical-parent'; }); parent.lifecycle.state = parent.lifecycle.state === 'needs_input' ? 'idle' : 'needs_input'; - // The egg maps state to status; the inventory reads status. - parent.lifecycle.status = parent.lifecycle.state === 'needs_input' ? 'blocked' : 'idle'; parent.lifecycle_seen_at = Date.now(); this.textContent = parent.lifecycle.state === 'needs_input' ? 'simulate parent ready' : 'simulate parent needs input'; render(); @@ -38,10 +36,10 @@ document.getElementById('fixture-disconnect').addEventListener('click', function render(); }); // Block every runtime action in this fixture, including keyboard attachment. -// Search, focus navigation, detail inspection and disconnect simulation work. +// Filters, focus navigation, detail inspection and disconnect simulation work. document.addEventListener('click', function(event) { var rowAction = event.target.closest('.egg-box, .session-tab') && !event.target.closest('.inventory-details'); - if (rowAction || event.target.closest('#detail-egg-connect, #detail-egg-rename, #detail-egg-delete, .session-fork-btn, .wing-box, #parent-dot')) { + if (rowAction || event.target.closest('.inventory-attach, .inventory-rename, .inventory-stop, .session-rename-btn, #detail-egg-connect, #detail-egg-delete, .wing-box, #parent-dot')) { event.preventDefault(); event.stopImmediatePropagation(); document.getElementById('fixture-notice').textContent = 'Synthetic UI fixture: runtime actions are disabled.'; } diff --git a/web/index.html b/web/index.html index 0ac7d7d5..376a7d56 100644 --- a/web/index.html +++ b/web/index.html @@ -29,7 +29,7 @@
- + @@ -100,7 +100,10 @@

connect a parent agent to every wing

- + + + +
@@ -200,7 +203,7 @@

connect a parent agent to every wing

- +
diff --git a/web/src/helpers.js b/web/src/helpers.js index 8c4f4a86..64fbba21 100644 --- a/web/src/helpers.js +++ b/web/src/helpers.js @@ -51,10 +51,11 @@ export function sessionDisplayName(session) { return projectName(session && session.cwd); } -// The header names the session; agent and wing are in its details. export function formatSessionTitle(session, agent, wingName) { - if (session) return sessionDisplayName(session); - return wingName || agent || ''; + var resolvedAgent = agent || (session && session.agent) || ''; + var label = session && session.name ? sessionDisplayName(session) : (wingName || ''); + if (label && resolvedAgent) return label + ' \u00b7 ' + resolvedAgent; + return label || resolvedAgent; } export function validSessionName(name) { diff --git a/web/src/pty.js b/web/src/pty.js index 9d911b5b..cb028be2 100644 --- a/web/src/pty.js +++ b/web/src/pty.js @@ -51,9 +51,8 @@ function handleTerminalControlError(ws, sessionId, wingId, message) { } function setSessionActions(active) { - // Copy appears only once there is a selection (see terminal.js). - DOM.terminalCopyBtn.style.display = 'none'; - DOM.terminalCopyBtn.disabled = true; + DOM.terminalCopyBtn.style.display = active ? '' : 'none'; + if (!active) DOM.terminalCopyBtn.disabled = true; // Leaving a terminal must not leave its end-session control in the header. if (!active) { DOM.sessionCloseBtn.style.display = 'none'; diff --git a/web/src/render.js b/web/src/render.js index 40e36aa9..bdd11b34 100644 --- a/web/src/render.js +++ b/web/src/render.js @@ -1,5 +1,5 @@ -import { S, DOM } from './state.js'; -import { escapeHtml, wingDisplayName, shortenPath, projectName, sessionDisplayName, validSessionName, formatRelativeTime, semverCompare, nestedRepoCount, agentWithIcon, dirParent, setupCopyable, formatSessionTitle } from './helpers.js'; +import { S, DOM, TERM_THUMB_PREFIX } from './state.js'; +import { escapeHtml, wingDisplayName, shortenPath, projectName, sessionDisplayName, validSessionName, formatRelativeTime, semverCompare, nestedRepoCount, agentIcon, agentWithIcon, dirParent, setupCopyable } from './helpers.js'; import { identityPubKey } from './crypto.js'; import { sendTunnelRequest, tunnelCloseWing } from './tunnel.js'; import { switchToSession, deleteSession } from './nav.js'; @@ -9,18 +9,19 @@ import { setLastTermAgent, getLastTermAgent, setWingOrder, setEggOrder, getCache import { rebuildAgentLists } from './dashboard.js'; import { openAuditReplay, openAuditKeylog, downloadChatHistory } from './audit.js'; import { showTerminal } from './nav.js'; +import { safeTerminalThumbnail } from './security.js'; import { shouldFetchWingSessions } from './session-merge.js'; import { updateCanvasSessionName } from './canvas.js'; import { historyResumeState } from './session-resume.js'; import { sessionForkAvailable, sessionForkControl } from './session-fork.js'; -import { notificationForSession } from './session-reference.js'; -import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionResourceKey, groupSessionInventory, groupSessionsByWing, sessionGroupHeader, sessionTabMarkup, sessionCardMarkup, inventorySearchVisible, inventoryCountLabel } from './session-inventory.js'; +import { readSessionContent, notificationForSession } from './session-reference.js'; +import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionResourceKey, groupSessionInventory, sessionGroupHeader, unseenCompletionBadge, sessionProjectRoot } from './session-inventory.js'; import { refreshConversationInventory } from './conversation-view.js'; import { refreshParentDot } from './parent-dot.js'; -import { unseenSessionCompletions } from './session-completion.js'; +import { unseenSessionCompletions, acknowledgeSessionCompletions } from './session-completion.js'; import { browserLocalStorage } from './storage-scope.js'; -var inventoryFilters = { query: '' }; +var inventoryFilters = { query: '', wing: '', agent: '', status: '' }; var sessionStopPending = new Set(); var sessionStopConfirm = new Map(); var sessionActionErrors = new Map(); @@ -35,7 +36,7 @@ function renderChannelBanner() { var preview = S.currentUser && S.currentUser.release_channel === 'preview'; banner.style.display = preview ? '' : 'none'; if (preview) banner.textContent = (S.currentUser.channel_label || 'Wingthing Preview') + - (S.currentUser.version ? ' · ' + S.currentUser.version : ''); + ' · personal preview' + (S.currentUser.version ? ' · ' + S.currentUser.version : ''); } function wingNameById(wingId) { @@ -63,6 +64,8 @@ function wingHasCapability(wingId, capability) { export function renderSidebar() { renderChannelBanner(); refreshParentDot(); + // Live inventory refreshes must not discard an unfinished rename. + if (DOM.sessionTabs.querySelector('.renaming, .forking')) return; var focus = captureSessionFocus(DOM.sessionTabs, document.activeElement); var unseen = unseenSessionCompletions(browserLocalStorage(), S.currentUser && S.currentUser.id); var sessions = S.sessionsData.filter(function(s) { @@ -71,15 +74,39 @@ export function renderSidebar() { return isWingVisible(s.wing_id); }); function renderTab(s) { - return sessionTabMarkup(s, sessionInventoryState(s, sessionWing(s), notificationForSession(S.sessionNotifications, s)), { - active: S.activeView === 'terminal' && sessionIsSelected(s, S.ptySessionId, S.ptyWingId), - unseen: unseen.has(sessionResourceKey(s)), - wingName: wingNameById(s.wing_id) - }); + var name = sessionDisplayName(s); + var letter = name.charAt(0).toUpperCase(); + var isActive = S.activeView === 'terminal' && sessionIsSelected(s, S.ptySessionId, S.ptyWingId); + var state = sessionInventoryState(s, sessionWing(s), notificationForSession(S.sessionNotifications, s)); + var ownsSession = !!S.currentUser && !!s.user_id && s.user_id === S.currentUser.id; + var canRename = sessionInventoryActions(s, sessionWing(s), S.currentUser).rename; + var title = name + ' \u00b7 ' + (s.agent || '?') + ' \u00b7 ' + wingNameById(s.wing_id) + ' \u00b7 ' + state.connectionLabel + ' \u00b7 ' + state.agentLabel; + if (!ownsSession) title += ' \u00b7 only the session owner can rename'; + else if (!canRename) title += ' \u00b7 update this wing to rename'; + if (unseen.has(sessionResourceKey(s))) title += ' · unseen completion'; + return '
' + + sessionStatusDot(state.status, true) + + '' + escapeHtml(letter) + '' + + '' + escapeHtml(name) + '' + + '' + escapeHtml((s.agent || '?') + ' · ' + state.agentLabel) + '' + unseenCompletionBadge(unseen.has(sessionResourceKey(s))) + '' + + (isActive ? sessionTabActions(s, name, canRename) : '') + + '
'; + } + // Only the open session carries actions, so rows never change shape or + // swap controls under the pointer. + function sessionTabActions(s, name, canRename) { + var actions = (canRename ? '' : '') + + sessionForkControl(s, sessionWing(s), S.currentUser); + return actions ? '' + actions + '' : ''; } - // Keep the grouped order; the group context lives in each row's tooltip. var groups = groupSessionInventory(sessions, S.wingsData, S.sessionNotifications, unseen); - DOM.sessionTabs.innerHTML = groups.map(function(group) { return group.sessions.map(renderTab).join(''); }).join(''); + DOM.sessionTabs.innerHTML = groups.map(function(group) { + return '
' + sessionGroupHeader(group) + group.sessions.map(renderTab).join('') + '
'; + }).join(''); + bindGroupAcknowledgements(DOM.sessionTabs, groups); DOM.sessionTabs.querySelectorAll('.session-tab').forEach(function(tab) { function openSession() { @@ -89,7 +116,10 @@ export function renderSidebar() { if (s && !s.swept) return; switchToSession(sid, undefined, tab.dataset.wingId); } - tab.addEventListener('click', openSession); + tab.addEventListener('click', function(e) { + if (e.target.closest('button, input, .forking')) return; + openSession(); + }); tab.addEventListener('keydown', function(e) { if (e.target === tab && navigateSessionRows(e, Array.from(DOM.sessionTabs.querySelectorAll('.session-tab')), tab)) return; if ((e.key === 'Enter' || e.key === ' ') && e.target === tab) { @@ -97,6 +127,20 @@ export function renderSidebar() { openSession(); } }); + var rename = tab.querySelector('.session-rename-btn'); + var fork = tab.querySelector('.session-fork-btn'); + if (fork) fork.addEventListener('click', function(e) { + e.stopPropagation(); + var session = findSessionResource(S.sessionsData, tab.dataset.sid, tab.dataset.wingId); + if (session) beginSessionFork(tab, session, session.wing_id, fork); + }); + if (rename) { + rename.addEventListener('click', function(e) { + e.stopPropagation(); + var session = findSessionResource(S.sessionsData, tab.dataset.sid, tab.dataset.wingId); + if (session) beginSessionRename(tab, session); + }); + } }); restoreSessionFocus(DOM.sessionTabs, focus); } @@ -152,7 +196,6 @@ function beginSessionFork(container, session, wingId, button) { S.sessionsData.push({ id: result.session, name: result.label, agent: result.agent, cwd: result.cwd, wing_id: wingId, user_id: session.user_id, status: 'detached', swept: true, conversation_id: result.conversation_id, root_conversation_id: result.root_conversation_id, parent_conversation_id: result.parent_conversation_id }); container.classList.remove('forking'); - if (container === DOM.detailDialog) hideDetailModal(); saveSessionCache(); renderSidebar(); refreshConversationInventory(); @@ -169,7 +212,7 @@ function beginSessionFork(container, session, wingId, button) { input.select(); } -function beginSessionRename(tab, session, onSettled) { +function beginSessionRename(tab, session) { if (tab.classList.contains('renaming')) return; tab.classList.add('renaming'); var label = tab.querySelector('.tab-label'); @@ -195,7 +238,6 @@ function beginSessionRename(tab, session, onSettled) { tab.classList.remove('renaming'); renderSidebar(); if (S.activeView === 'home') renderSessionInventory(); - if (onSettled) onSettled(); } function save() { if (settled) return; @@ -216,12 +258,11 @@ function beginSessionRename(tab, session, onSettled) { saveSessionCache(); updateCanvasSessionName(session.id, session.name, session.wing_id); if (sessionIsSelected(session, S.ptySessionId, S.ptyWingId) && S.activeView === 'terminal') { - DOM.headerTitle.textContent = formatSessionTitle(session); + DOM.headerTitle.textContent = session.name + ' \u00b7 ' + (session.agent || '?'); } tab.classList.remove('renaming'); renderSidebar(); if (S.activeView === 'home') renderDashboard(); - if (onSettled) onSettled(); }) .catch(function(err) { settled = false; @@ -1331,24 +1372,9 @@ function loadOrgMembers(org, containerId) { }); } -var detailReturnFocus = null; - -// Move focus into the dialog so keyboard users reach rename and fork, and -// remember where it came from. A re-render of an open dialog keeps the origin. -function openDetailModal() { - if (!DOM.detailOverlay.classList.contains('open')) detailReturnFocus = document.activeElement; - DOM.detailOverlay.classList.add('open'); - var first = DOM.detailDialog.querySelector('.detail-actions button:not(:disabled):not([hidden])'); - (first || DOM.detailDialog).focus({ preventScroll: true }); -} - export function hideDetailModal() { DOM.detailOverlay.classList.remove('open'); - DOM.detailDialog.classList.remove('forking'); DOM.detailDialog.innerHTML = ''; - var origin = detailReturnFocus; - detailReturnFocus = null; - if (origin && origin.isConnected && typeof origin.focus === 'function') origin.focus({ preventScroll: true }); } export function renderWingDetailPage(wingId) { @@ -2289,49 +2315,29 @@ export function showEggDetail(sessionId, wingId) { escapeHtml(parts.join(' | ')) + ''; } - var viewing = S.activeView === 'terminal' && sessionIsSelected(s, S.ptySessionId, S.ptyWingId); - var owner = s.user_id && !(S.currentUser && s.user_id === S.currentUser.id) ? s.email || 'unknown owner' : ''; DOM.detailDialog.innerHTML = - '

' + escapeHtml(name) + '

' + + '

' + escapeHtml(name) + ' · ' + escapeHtml(s.agent || '?') + '

' + '
session' + escapeHtml(s.id) + '
' + '
wing' + escapeHtml(wingName || 'unknown') + '
' + '
type' + escapeHtml(kind) + '
' + '
agent' + escapeHtml(s.agent || '?') + '
' + '
cwd' + escapeHtml(cwdDisplay) + '
' + - (owner ? '
owner' + escapeHtml(owner) + '
' : '') + - (s.conversation_role ? '
role' + escapeHtml(s.conversation_role) + '
' : '') + '
agent state' + escapeHtml(state.agentLabel) + '
' + '
connection' + escapeHtml(state.connectionLabel + ' · ' + state.attachment) + '
' + - (viewing ? '
e2e' + (S.e2eKey ? 'active' : 'none') + '
' : '') + configSummary + '
' + - (viewing ? '' : '') + - (actions.rename ? '' : '') + - sessionForkControl(s, sessionWing(s), S.currentUser) + + '' + (actions.stop ? '' : '') + '
'; setupCopyable(DOM.detailDialog); - openDetailModal(); + DOM.detailOverlay.classList.add('open'); - var connectBtn = document.getElementById('detail-egg-connect'); - if (connectBtn) connectBtn.addEventListener('click', function() { + document.getElementById('detail-egg-connect').addEventListener('click', function() { hideDetailModal(); switchToSession(sessionId, undefined, s.wing_id); }); - var renameBtn = document.getElementById('detail-egg-rename'); - if (renameBtn) renameBtn.addEventListener('click', function() { - renameBtn.hidden = true; - beginSessionRename(DOM.detailDialog.querySelector('h3'), s, function() { - // The dialog may have been dismissed while the rename was saving. - if (DOM.detailOverlay.classList.contains('open')) showEggDetail(s.id, s.wing_id); - }); - }); - - var forkBtn = DOM.detailDialog.querySelector('.session-fork-btn'); - if (forkBtn) forkBtn.addEventListener('click', function() { beginSessionFork(DOM.detailDialog, s, s.wing_id, forkBtn); }); - var delBtn = document.getElementById('detail-egg-delete'); if (delBtn) delBtn.addEventListener('click', function() { stopInventorySession(s, delBtn, hideDetailModal); @@ -2340,10 +2346,8 @@ export function showEggDetail(sessionId, wingId) { export function showSessionInfo() { var s = findSessionResource(S.sessionsData, S.ptySessionId, S.ptyWingId); - // A known session gets the same details and actions as its Home card. - if (s) { showEggDetail(s.id, s.wing_id); return; } var w = S.ptyWingId ? S.wingsData.find(function(w) { return w.wing_id === S.ptyWingId; }) : null; - if (!w) return; + if (!s && !w) return; var wingName = w ? wingDisplayName(w) : 'unknown'; var agent = s ? (s.agent || '?') : '?'; @@ -2379,7 +2383,7 @@ export function showSessionInfo() { '
agents' + escapeHtml(wingAgents) + '
'; setupCopyable(DOM.detailDialog); - openDetailModal(); + DOM.detailOverlay.classList.add('open'); } export function renderDashboard() { @@ -2409,16 +2413,21 @@ export function renderDashboard() { (w.passkey_enrolled && !w.locked ? '🔑' : '')); var draggable = ('ontouchstart' in window || navigator.maxTouchPoints > 0) ? '' : ' draggable="true"'; var isMine = S.currentUser && w.user_id === S.currentUser.id; - // One line: the dot carries normal state; only a reason the wing - // cannot be used is spelled out. The rest is on the wing page. - var exception = isUnreachable ? 'unreachable' : - (needsPasskeySetup || needsAuth || isCardPasskey) ? lockedBadge : - w.online === false ? 'offline' : ''; - var summary = [name, plat, (w.agents || []).join(', '), projectCount ? projectCount + ' proj' : '', w.owner && !isMine ? w.owner : ''].filter(Boolean).join(' · '); - return '
' + - '' + - '' + escapeHtml(name) + lockIcon + '' + - exception + + var ownerTag = (w.owner && !isMine) ? '' + escapeHtml(w.owner) + '' : ''; + return '
' + + '
' + + '' + + '' + escapeHtml(name) + lockIcon + '' + + ownerTag + + '
' + + '' + ((needsPasskeySetup || needsAuth || isCardPasskey) ? '' : ((w.agents || []).length === 0 ? 'no agents' : (w.agents || []).map(function(a) { + return agentIcon(a) || escapeHtml(a); + }).join(' '))) + '' + + '
' + + '' + escapeHtml(plat) + '' + (w.online === true ? 'online' : w.online === false ? 'offline' : 'checking') + '' + + (isUnreachable ? 'unreachable' : + ((needsPasskeySetup || needsAuth || isCardPasskey) ? lockedBadge : (projectCount ? '' + projectCount + ' proj' : ''))) + + '
' + '
'; }).join(''); wingHtml += '
'; @@ -2500,18 +2509,22 @@ function initSessionInventoryControls() { var controls = document.getElementById('session-inventory-controls'); if (!controls || controls.dataset.bound) return; controls.dataset.bound = '1'; - var search = document.getElementById('session-inventory-search'); - search.addEventListener('input', function() { - inventoryFilters.query = search.value; - renderSessionInventory(); + ['query', 'wing', 'agent', 'status'].forEach(function(key) { + var input = document.getElementById('session-inventory-' + (key === 'query' ? 'search' : key)); + input.addEventListener(key === 'query' ? 'input' : 'change', function() { + inventoryFilters[key] = input.value; + renderSessionInventory(); + }); }); document.getElementById('session-inventory-clear').addEventListener('click', function() { - inventoryFilters = { query: '' }; - search.value = ''; + inventoryFilters = { query: '', wing: '', agent: '', status: '' }; + ['search', 'wing', 'agent', 'status'].forEach(function(key) { + document.getElementById('session-inventory-' + key).value = ''; + }); renderSessionInventory(); - search.focus(); + document.getElementById('session-inventory-search').focus(); }); - search.addEventListener('keydown', function(event) { + document.getElementById('session-inventory-search').addEventListener('keydown', function(event) { if (event.key === 'ArrowDown') { var first = DOM.sessionsList.querySelector('.egg-box'); if (first) { event.preventDefault(); first.focus(); } @@ -2524,7 +2537,26 @@ function initSessionInventoryControls() { }); } +function updateInventoryOptions(id, entries, allLabel, value) { + var select = document.getElementById(id); + if (!select) return; + // Replacing options only when they changed preserves a focused native menu. + if (value && !entries.some(function(entry) { return entry.value === value; })) entries.push({ value: value, label: value + ' (unavailable)' }); + var html = '' + entries.map(function(entry) { + return ''; + }).join(''); + if (select.dataset.options !== html) { + select.innerHTML = html; + select.dataset.options = html; + } + select.value = value; + // A filter with one choice cannot narrow anything; keep an active one clearable. + if (select.closest('label')) select.closest('label').hidden = entries.length < 2 && !value; +} + export function renderSessionInventory() { + // Preserve the active editor while background status probes finish. + if (DOM.sessionsList.querySelector('.renaming, .forking')) return; var focus = captureSessionFocus(DOM.sessionsList, document.activeElement); var allSessions = visibleInventorySessions(); var hasWings = S.wingsData.some(function(wing) { return wingDisplayName(wing); }); @@ -2543,78 +2575,117 @@ export function renderSessionInventory() { if (controls) controls.style.display = allSessions.length ? '' : 'none'; if (!allSessions.length) { DOM.sessionsList.innerHTML = ''; return; } + updateInventoryOptions('session-inventory-wing', S.wingsData.filter(function(wing) { + return allSessions.some(function(session) { return session.wing_id === wing.wing_id; }); + }).map(function(wing) { return { value: wing.wing_id, label: wingDisplayName(wing) || wing.wing_id }; }), 'all wings', inventoryFilters.wing); + updateInventoryOptions('session-inventory-agent', Array.from(new Set(allSessions.map(function(session) { + return session.agent; + }).filter(Boolean))).sort().map(function(agent) { return { value: agent, label: agent }; }), 'all agents', inventoryFilters.agent); var sessions = filterSessionInventory(allSessions, S.wingsData, S.sessionNotifications, inventoryFilters); var unseen = unseenSessionCompletions(browserLocalStorage(), S.currentUser && S.currentUser.id); - var filtered = !!inventoryFilters.query; - // Search appears only once the list is long enough to need it. - var search = document.querySelector('#session-inventory-controls .inventory-search'); - if (search) search.hidden = !inventorySearchVisible(allSessions.length, inventoryFilters.query); var count = document.getElementById('session-inventory-count'); - if (count) count.textContent = inventoryCountLabel(allSessions.length, sessions.length, filtered); + var filtered = Object.values(inventoryFilters).some(Boolean); + var attention = sessions.filter(function(session) { + return sessionInventoryState(session, sessionWing(session), notificationForSession(S.sessionNotifications, session)).attention; + }).length; + if (count) count.textContent = (filtered ? sessions.length + ' of ' : '') + allSessions.length + (attention ? ' · ' + attention + (attention === 1 ? ' needs' : ' need') + ' attention' : ''); var clear = document.getElementById('session-inventory-clear'); if (clear) { clear.disabled = !filtered; clear.hidden = !filtered; } if (!sessions.length) { - DOM.sessionsList.innerHTML = '
No sessions match this search. Clear it to see every session.
'; + DOM.sessionsList.innerHTML = '
No sessions match these filters. Clear filters to see every session.
'; return; } function renderEggCard(session) { + var name = sessionDisplayName(session); var wing = sessionWing(session); + var state = sessionInventoryState(session, wing, notificationForSession(S.sessionNotifications, session)); + var actions = sessionInventoryActions(session, wing, S.currentUser); + var selected = S.activeView === 'terminal' && sessionIsSelected(session, S.ptySessionId, S.ptyWingId); + var thumbnail = ''; + try { thumbnail = readSessionContent(localStorage, TERM_THUMB_PREFIX, session.wing_id, session.id) || ''; } catch (error) {} + thumbnail = safeTerminalThumbnail(thumbnail); + var sid = escapeHtml(session.id); + var role = session.conversation_role; + var label = name + ' · ' + (session.agent || 'unknown agent') + ' · ' + (wing && wingDisplayName(wing) || 'unknown wing'); var resourceKey = sessionResourceKey(session); - return sessionCardMarkup(session, sessionInventoryState(session, wing, notificationForSession(S.sessionNotifications, session)), { - wingName: wing && wingDisplayName(wing), - selected: S.activeView === 'terminal' && sessionIsSelected(session, S.ptySessionId, S.ptyWingId), - unseen: unseen.has(resourceKey), - owner: S.currentUser && session.user_id && session.user_id !== S.currentUser.id ? session.email || 'unknown owner' : '', - error: sessionActionErrors.get(resourceKey) - }); + var error = sessionActionErrors.get(resourceKey); + var unread = unseen.has(resourceKey); + return '
' + + (thumbnail ? '
' : '') + + '' + + '
' + agentWithIcon(session.agent || '?') + '·' + escapeHtml(state.agentLabel) + '
' + + // The group header names the wing and project; repeat only what differs. + (S.currentUser && S.currentUser.roost_mode && session.user_id !== S.currentUser.id ? '
' + escapeHtml(session.email || 'unknown owner') + '
' : '') + + ((session.cwd || '').replace(/\/+$/, '') !== sessionProjectRoot(session, wing) ? '
' + escapeHtml(shortenPath(session.cwd || '~')) + '
' : '') + + (unread || state.connection !== 'available' ? '
' + unseenCompletionBadge(unread) + (state.connection !== 'available' ? '' + escapeHtml(state.connectionLabel) + '' : '') + '
' : '') + + '
' + + '' + + '' + + (actions.rename ? '' : '') + + sessionForkControl(session, wing, S.currentUser) + + '
' + escapeHtml(error || '') + '
'; } - // A wing name heads its cards only when more than one wing has sessions; - // the project stays in the card's details. Count wings over the whole - // inventory so a search cannot make headers flicker. - var wings = new Set(allSessions.map(function(session) { return session.wing_id || ''; })); - var groups = groupSessionsByWing(groupSessionInventory(sessions, S.wingsData, S.sessionNotifications, unseen)); + var groups = groupSessionInventory(sessions, S.wingsData, S.sessionNotifications, unseen); DOM.sessionsList.innerHTML = groups.map(function(group) { - return '
' + (wings.size > 1 ? sessionGroupHeader(group, { compact: true }) : '') + - '
' + group.sessions.map(renderEggCard).join('') + '
'; + return '
' + sessionGroupHeader(group) + '
' + group.sessions.map(renderEggCard).join('') + '
'; }).join(''); + bindGroupAcknowledgements(DOM.sessionsList, groups); var cards = Array.from(DOM.sessionsList.querySelectorAll('.egg-box')); cards.forEach(function(card) { var session = findSessionResource(S.sessionsData, card.dataset.sid, card.dataset.wingId); - // An unavailable session opens its details rather than doing nothing. - function open() { + function attach() { if (sessionInventoryActions(session, sessionWing(session), S.currentUser).attach) switchToSession(session.id, undefined, session.wing_id); - else showEggDetail(session.id, session.wing_id); } - card.addEventListener('click', function(event) { if (!event.target.closest('button, input')) open(); }); + card.addEventListener('click', function(event) { if (!event.target.closest('button, input')) attach(); }); card.addEventListener('keydown', function(event) { if (event.target !== card) return; if (navigateSessionRows(event, cards, card)) return; - if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); } + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); attach(); } }); + card.querySelector('.inventory-attach').addEventListener('click', attach); card.querySelector('.inventory-details').addEventListener('click', function() { showEggDetail(session.id, session.wing_id); }); + var rename = card.querySelector('.inventory-rename'); + var fork = card.querySelector('.session-fork-btn'); + if (fork) fork.addEventListener('click', function() { beginSessionFork(card, session, session.wing_id, fork); }); + if (rename) rename.addEventListener('click', function() { beginSessionRename(card, session); }); + var stop = card.querySelector('.inventory-stop'); + if (stop) stop.addEventListener('click', function() { stopInventorySession(session, stop); }); }); setupEggDrag(); - if (focus && !restoreSessionFocus(DOM.sessionsList, focus)) { - // The search box is hidden on short lists; never focus a hidden field. - var fallback = inventorySearchVisible(allSessions.length, inventoryFilters.query) ? document.getElementById('session-inventory-search') : DOM.sessionsList.querySelector('.egg-box'); - if (fallback) fallback.focus({ preventScroll: true }); - } + if (focus && !restoreSessionFocus(DOM.sessionsList, focus)) document.getElementById('session-inventory-search').focus({ preventScroll: true }); +} + +function bindGroupAcknowledgements(container, groups) { + container.querySelectorAll('.inventory-project-group').forEach(function(section) { + var button = section.querySelector('.inventory-acknowledge'); + if (!button) return; + var group = groups.find(function(group) { return group.key === section.dataset.groupKey; }); + button.addEventListener('click', function() { + acknowledgeSessionCompletions(browserLocalStorage(), S.currentUser && S.currentUser.id, group.sessions); + renderSidebar(); + if (S.activeView === 'home') renderSessionInventory(); + refreshConversationInventory(); + var first = group.sessions[0]; + var row = Array.from(container.querySelectorAll('[data-sid]')).find(function(row) { return row.dataset.sid === first.id && row.dataset.wingId === first.wing_id; }); + if (row) row.focus({ preventScroll: true }); + }); + }); } function stopInventorySession(session, button, onStopped) { var key = sessionResourceKey(session); if (!sessionInventoryActions(session, sessionWing(session), S.currentUser).stop || sessionStopPending.has(key)) return; if ((sessionStopConfirm.get(key) || 0) <= Date.now()) { - var label = button.textContent; sessionStopConfirm.set(key, Date.now() + 4000); button.textContent = 'stop now?'; button.classList.add('btn-armed'); setTimeout(function() { - if ((sessionStopConfirm.get(key) || 0) > Date.now() || sessionStopPending.has(key)) return; - button.textContent = label; - button.classList.remove('btn-armed'); + if ((sessionStopConfirm.get(key) || 0) <= Date.now() && S.activeView === 'home') renderSessionInventory(); }, 4100); return; } diff --git a/web/src/session-inventory.js b/web/src/session-inventory.js index 73e4b009..9f14b080 100644 --- a/web/src/session-inventory.js +++ b/web/src/session-inventory.js @@ -117,7 +117,7 @@ export function groupSessionInventory(sessions, wings, notifications = {}, unsee var project = sessionProjectRoot(session, wing); var key = JSON.stringify([session.wing_id || '', project]); if (!groups.has(key)) groups.set(key, { key: key, wingId: session.wing_id || '', wing: wing, project: project, - sessions: [], rollup: { blocked: 0, working: 0, idle: 0, unseen: 0 }, priority: 3, priorities: priorities }); + sessions: [], rollup: { blocked: 0, working: 0, idle: 0, unseen: 0 }, priority: 3 }); var group = groups.get(key); var status = statusForSession ? statusForSession(session) : sessionInventoryState(session, wing, notificationForSession(notifications, session)).status; var unread = unseen.has(sessionResourceKey(session)); @@ -134,87 +134,14 @@ export function groupSessionInventory(sessions, wings, notifications = {}, unsee }); } -// Home labels wings, not projects: project groups of one session each would -// put a header over every card. Keeps the attention order across projects. -export function groupSessionsByWing(groups) { - var wings = new Map(); - groups.forEach(function(group) { - if (!wings.has(group.wingId)) wings.set(group.wingId, { key: JSON.stringify([group.wingId]), wingId: group.wingId, wing: group.wing, sessions: [] }); - var wing = wings.get(group.wingId); - wing.sessions = wing.sessions.concat(group.sessions); - wing.priorities = group.priorities; - }); - return Array.from(wings.values()).map(function(wing) { - wing.sessions.sort(function(a, b) { return wing.priorities.get(a) - wing.priorities.get(b); }); - return wing; - }); -} - export function unseenCompletionBadge(unseen) { return unseen ? '' + (typeof unseen === 'number' ? unseen + ' ' : '') + 'unseen completion' + (unseen > 1 ? 's' : '') + '' : ''; } -// Only exceptions earn a visible word; the dot already carries normal states. -// A wing still being checked is transient, so it prints nothing either. -export function sessionExceptionLabel(state) { - if (state.connection !== 'available' && state.connection !== 'checking') return state.connectionLabel; - if (state.status === 'blocked') return 'needs input'; - if (state.status === 'exited') return 'exited'; - return state.attention ? 'attention' : ''; -} - -export var INVENTORY_SEARCH_MIN = 8; - -export function inventorySearchVisible(total, query) { - return !!query || total >= INVENTORY_SEARCH_MIN; -} - -export function inventoryCountLabel(total, shown, filtered) { - return filtered ? shown + ' of ' + total : ''; -} - -function resourceAttributes(session) { - return 'data-sid="' + escapeHtml(session.id) + '" data-wing-id="' + escapeHtml(session.wing_id || '') + '"'; -} - -// One line per session: dot and name. Every other fact stays in the tooltip -// and accessible label; actions live in the details dialog. -export function sessionTabMarkup(session, state, opts) { - opts = opts || {}; - var name = sessionDisplayName(session); - var title = [name, session.agent || '?', opts.wingName || '', session.cwd || '~', state.connectionLabel, state.agentLabel].join(' · ') + - (opts.unseen ? ' · unseen completion' : ''); - // Attention short of blocked marks the dot, never the words "needs input". - return '
' + - sessionStatusDot(state.status, true) + - '' + escapeHtml(name.charAt(0).toUpperCase()) + '' + - '' + escapeHtml(name) + '
'; -} - -export function sessionCardMarkup(session, state, opts) { - opts = opts || {}; - var name = sessionDisplayName(session); - var exception = sessionExceptionLabel(state); - var label = [name, session.agent || 'unknown agent', opts.wingName || 'unknown wing', session.cwd || '~'].concat(opts.owner ? [opts.owner] : []) - .concat([state.connectionLabel, state.agentLabel]).join(' · ') + (opts.unseen ? ' · unseen completion' : ''); - return '
' + - '' + - (opts.error ? '
' + escapeHtml(opts.error) + '
' : '') + '
'; -} - -export function sessionGroupHeader(group, opts) { +export function sessionGroupHeader(group) { var counts = group.rollup; // The wing ID stays in the tooltip; the visible label is the wing's name. var wing = wingDisplayName(group.wing) || group.wingId || 'unknown wing'; - if (opts && opts.compact) { - return '

' + escapeHtml(wing) + '

'; - } var path = group.project ? shortenPath(group.project) : 'no project'; var rollup = ['blocked', 'working', 'idle'].filter(function(status) { return counts[status] > 0; }).map(function(status) { return '' + counts[status] + ' ' + status + ''; diff --git a/web/src/terminal.js b/web/src/terminal.js index a51bac99..66626378 100644 --- a/web/src/terminal.js +++ b/web/src/terminal.js @@ -124,10 +124,10 @@ export function initTerminal() { }); S.term.onSelectionChange(function() { - var selected = S.term.hasSelection(); - DOM.terminalCopyBtn.disabled = !selected; - DOM.terminalCopyBtn.style.display = selected && S.ptySessionId ? '' : 'none'; - DOM.terminalCopyBtn.title = 'Copy selection without terminal line padding'; + DOM.terminalCopyBtn.disabled = !S.term.hasSelection(); + DOM.terminalCopyBtn.title = S.term.hasSelection() + ? 'Copy selection without terminal line padding' + : 'Select terminal text to copy'; }); DOM.terminalCopyBtn.addEventListener('click', function() { copyTerminalSelection(); }); diff --git a/web/style.css b/web/style.css index 45cbc790..46daa568 100644 --- a/web/style.css +++ b/web/style.css @@ -68,7 +68,7 @@ body { display: flex; flex-direction: column; align-items: stretch; - padding: 8px 6px; + padding: 8px; flex-shrink: 0; gap: 2px; overflow-y: auto; @@ -79,12 +79,12 @@ body { #home-btn { width: 100%; - height: 32px; + height: 36px; border-radius: 8px; background: transparent; border: none; color: var(--text-muted); - font-size: 14px; + font-size: 18px; font-weight: 600; cursor: pointer; display: flex; @@ -104,13 +104,13 @@ body { #new-session-btn { width: 100%; - height: 32px; + height: 36px; border-radius: 8px; background: transparent; border: 1px dashed var(--text-muted); color: var(--text-dim); - font-size: 14px; - font-weight: 400; + font-size: 18px; + font-weight: 300; cursor: pointer; display: flex; align-items: center; @@ -145,20 +145,20 @@ body { .session-tab { width: 100%; - min-height: 36px; + min-height: 44px; border-radius: 6px; background: transparent; border: none; color: var(--text-dim); font-size: 14px; - font-weight: 500; + font-weight: 600; cursor: pointer; display: flex; flex-direction: row; align-items: center; justify-content: center; - gap: 6px; - padding: 0 6px; + gap: 8px; + padding: 6px 8px; position: relative; transition: all 0.15s; } @@ -172,6 +172,34 @@ body { text-align: left; } +.session-tab:has(.session-tab-actions) { flex-wrap: wrap; } + +.session-tab-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 12px; + flex-basis: 100%; + padding-left: 14px; +} + +.session-tab-actions button { + background: none; + border: 0; + border-radius: 0; + padding: 2px 0; + color: var(--text-dim); + font-family: var(--font); + font-size: 11px; + font-weight: 400; + cursor: pointer; +} + +.session-tab-actions button:hover:not(:disabled) { background: none; color: var(--accent); } + +.session-tab.renaming .session-tab-actions, +.session-tab.forking .session-rename-btn { display: none; } + .session-fork-form { display: flex; flex-wrap: wrap; @@ -220,10 +248,6 @@ body { color: var(--text); } -/* An unseen completion is a bold name, not a badge. */ -.session-tab[data-unseen="true"] .tab-label, -.egg-box[data-unseen="true"] .egg-label { color: var(--text); font-weight: 600; } - .session-tab.active { background: var(--bg-card); color: var(--text); @@ -245,8 +269,6 @@ body { .tab-dot.dot-live { background: var(--green); } .tab-dot.dot-detached { background: var(--yellow); } -/* Attention rings the status dot; the fill still reports the agent state. */ -.session-tab[data-attention="true"] .tab-dot { box-shadow: 0 0 0 2px #12122a, 0 0 0 3.5px var(--yellow); } .tab-dot.dot-attention { background: var(--orange); animation: pulse 1s ease-in-out infinite; @@ -325,24 +347,18 @@ body { flex: 1; display: flex; align-items: center; - gap: 14px; + gap: 8px; min-width: 0; } #header-title { - background: none; - border: 0; - padding: 0; - font: inherit; font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - min-width: 0; cursor: pointer; } -#header-title:empty { display: none; } #header-title:hover { color: var(--text); } #pty-status { @@ -369,23 +385,23 @@ body { background: rgba(233, 69, 96, 0.1); } -/* Header session controls are plain words; only x keeps an outline. */ +/* Header session controls share one quiet outline; .btn-sm fills are for cards. */ #main-header .session-action, #main-header #canvas-toggle-btn { background: none; - border: 0; + border: 1px solid var(--border); color: var(--text-dim); font-family: var(--font); font-size: 12px; font-weight: 400; - padding: 2px 0; - border-radius: 0; + padding: 2px 6px; + border-radius: 3px; } #main-header .session-action:hover:not(:disabled), -#main-header #canvas-toggle-btn:hover, -#main-header .session-action[aria-pressed="true"] { +#main-header #canvas-toggle-btn:hover { color: var(--accent); - background: none; + border-color: var(--accent); + background: rgba(233, 69, 96, 0.1); } .session-action[aria-pressed="true"] { @@ -491,25 +507,52 @@ body { /* === Wing boxes === */ -.wing-grid { display: flex; flex-wrap: wrap; gap: 8px; } +.wing-grid { display: flex; flex-wrap: wrap; gap: 10px; } .wing-box { width: 280px; - height: 44px; display: flex; - align-items: center; + flex-direction: column; gap: 8px; background: var(--bg-card); - border-radius: 6px; - padding: 0 12px; + border-radius: 8px; + padding: 14px 16px; cursor: grab; user-select: none; transition: opacity 0.15s; position: relative; } -.wing-name { font-weight: 500; font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.wing-exception, .wing-pinned-badge { margin-left: auto; flex-shrink: 0; font-size: 12px; color: var(--text-dim); } +.wing-box-top { + display: flex; + align-items: center; + gap: 8px; +} + +.wing-name { font-weight: 600; font-size: 15px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wing-owner { font-size: 12px; color: var(--text-dim); margin-left: auto; flex-shrink: 0; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.wing-agents { + font-size: 14px; + color: var(--text-dim); +} + +.wing-agents .agent-icon { + width: 18px; + height: 18px; + vertical-align: -3px; + margin-right: 4px; +} + +.wing-statusbar { + display: flex; + justify-content: space-between; + font-size: 13px; + color: var(--text-muted); + border-top: 1px solid var(--border); + padding-top: 6px; + margin-top: auto; +} .wing-dot { width: 8px; @@ -557,7 +600,6 @@ body { } .wing-box:hover .box-menu-btn, .egg-box:hover .box-menu-btn, -.egg-box:focus-within .box-menu-btn, .box-menu-btn.active { opacity: 1; } .box-menu-btn:hover { color: var(--accent); } @@ -640,23 +682,13 @@ body { .box-detail-dialog .detail-actions { display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - gap: 8px; + flex-direction: column; + gap: 6px; margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px; } -.box-detail-dialog .detail-actions .btn-sm { width: auto; margin-top: 0; } -.box-detail-dialog .detail-actions .btn-danger { margin-left: auto; } - -/* Rename feedback flows under the input; the row-anchored popover would - land at the dialog's bottom edge. */ -.box-detail-dialog h3.renaming .session-name-input { display: block; width: 100%; font-size: 14px; padding: 6px 8px; } -.box-detail-dialog .session-rename-status:not(:empty) { position: static; display: block; margin-top: 6px; font-size: 12px; font-weight: 400; } - .btn-accent { background: var(--accent); color: #fff; @@ -2384,11 +2416,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } @media (max-width: 600px) { :root { --sidebar-w: 52px; } - #sidebar { padding: 8px 4px; } - #home-btn, #new-session-btn { justify-content: center; font-size: 18px; height: 44px; } - .session-tab { min-height: 44px; } - #main-header .session-action { min-height: 44px; padding: 0 6px; } - .nav-label, #session-tabs .tab-label { display: none; } + #sidebar { padding: 8px; } + #home-btn, #new-session-btn { justify-content: center; } + .nav-label, .tab-label, .session-rename-btn, .session-tab-actions, #session-tabs .session-fork-form { display: none; } .nav-symbol, .tab-letter { display: inline; } .session-tab { justify-content: center; padding: 0; } #session-files-panel { right: 8px; width: calc(100vw - 16px); } @@ -2401,11 +2431,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .parent-dot-name { display: none; } #modifier-keys { display: flex; flex-wrap: wrap; } .mod-key { flex: 1; min-width: 40px; } - .wing-box { width: 100%; height: 48px; } - .wing-name { font-size: 14px; } - .egg-box { width: 100%; } + .wing-box { width: 100%; padding: 10px; } + .egg-box { width: 220px; } .egg-preview { height: 120px; } - .inventory-session .box-menu-btn { opacity: 1; } + .egg-delete { opacity: 1; } #palette-dialog { margin: 0 12px; } } @@ -2680,7 +2709,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* Session inventory: visible controls and distinct provider/connection state. */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } :focus-visible { outline: 2px solid var(--accent-hover); outline-offset: 3px; } -#channel-banner { margin: 0 0 6px; padding: 4px 0; color: var(--yellow); font-size: 12px; opacity: 0.8; } +#channel-banner { margin: 4px 0 10px; padding: 9px 12px; border: 1px solid var(--yellow); border-radius: 6px; color: var(--yellow); font-size: 12px; } .inventory-project-group { min-width: 0; margin-bottom: 18px; } .inventory-project-group:last-child { margin-bottom: 0; } .inventory-group-header { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-bottom: 8px; padding: 4px 0 4px 10px; border-left: 2px solid var(--border); } @@ -2693,53 +2722,77 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .inventory-project-group[data-blocked="true"] .rollup-blocked { color: var(--yellow); font-weight: 700; } .unseen-completion { display: inline-block; align-self: flex-start; color: var(--green); border: 1px solid currentColor; border-radius: 4px; padding: 2px 5px; font-size: 11px; } .inventory-project-group[data-blocked="true"] .unseen-completion { color: #aeb8cb; } -#sessions-list .inventory-session[data-blocked="true"] { box-shadow: inset 2px 0 0 var(--yellow); } +#sessions-list .inventory-session[data-blocked="true"] { border-color: var(--yellow); box-shadow: inset 3px 0 0 var(--yellow); } .session-tab[data-blocked="true"] { box-shadow: inset 2px 0 0 var(--yellow); } -#session-inventory-controls { margin: 12px 0 8px; } -.inventory-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 6px; } -.inventory-heading .section-label { margin: 0; } +.inventory-session-owner { color: #aeb8cb; font-size: 11px; margin-top: 5px; overflow-wrap: anywhere; } +#session-tabs .inventory-group-header { padding: 2px 0 2px 8px; gap: 4px; margin: 4px 0 6px; } +#session-tabs .inventory-group-header h4 { flex-basis: 100%; font-size: 11px; } +#session-tabs .inventory-group-wing, #session-tabs .inventory-rollup { font-size: 10px; } +#session-tabs .inventory-acknowledge { font-size: 10px; } +#session-tabs .inventory-project-group { margin-bottom: 8px; } +#session-inventory-controls { margin: 18px 0 14px; } +.inventory-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 10px; } +.inventory-heading .section-label { margin: 0; color: var(--text); } #session-inventory-count { color: #aeb8cb; font-size: 12px; } .inventory-filters { display: flex; flex-wrap: wrap; gap: 8px; } .inventory-filters label { display: flex; min-width: 0; } .inventory-filters label[hidden], #session-inventory-clear[hidden] { display: none; } -.inventory-search { flex: 0 1 320px; } -.inventory-filters input { background: var(--bg-card); color: var(--text); border: 1px solid var(--border); border-radius: 4px; min-height: 30px; padding: 5px 8px; font: inherit; font-size: 12px; min-width: 0; } +.inventory-search { flex: 1 1 260px; } +.inventory-filters input, .inventory-filters select { background: var(--bg-card); color: var(--text); border: 1px solid var(--border); border-radius: 6px; min-height: 40px; padding: 8px 10px; font: inherit; font-size: 12px; min-width: 0; } .inventory-search input { width: 100%; } .inventory-filters input::placeholder { color: #aeb8cb; } +.inventory-filters select { max-width: 200px; } +.inventory-filters .btn-sm { min-height: 40px; } .inventory-no-results { padding: 28px 16px; border: 1px dashed var(--border); border-radius: 8px; color: #aeb8cb; font-size: 13px; } -#sessions-list .egg-grid { gap: 8px; } -#sessions-list .inventory-project-group { margin-bottom: 14px; } -#sessions-list .inventory-group-header { display: block; margin: 0 0 6px; padding: 0; border: 0; } -#sessions-list .inventory-group-header h4 { display: block; margin: 0; font-size: 12px; font-weight: 400; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#sessions-list .inventory-group-project, #sessions-list .inventory-group-wing { color: inherit; font-size: inherit; } -#sessions-list .inventory-session { width: 280px; border-radius: 6px; } -.inventory-session.selected { box-shadow: inset 2px 0 0 var(--accent); } -.inventory-session .egg-footer { min-height: 44px; padding: 0 6px 0 12px; font-size: 13px; } -.inventory-session .box-menu-btn { margin-left: 0; width: 28px; flex-shrink: 0; } -.inventory-exception { flex-shrink: 0; font-size: 12px; color: var(--text-dim); } -.inventory-exception.status-attention { color: var(--yellow); } -.inventory-exception.status-failed { color: #ff8b84; } +#sessions-list .egg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 285px), 1fr)); align-items: start; gap: 12px; } +#sessions-list .inventory-session { width: auto; min-width: 0; border: 1px solid var(--border); padding: 14px; } +.inventory-session.selected { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); } +.inventory-session .egg-footer { padding: 0; gap: 8px; } +.inventory-session .egg-label { display: block; font-size: 15px; font-weight: 700; min-width: 0; flex: 1; } +.inventory-session .egg-preview { height: 110px; margin: -14px -14px 12px; border-radius: 7px 7px 0 0; } +.inventory-session-meta { display: flex; align-items: center; gap: 7px; margin-top: 8px; color: #bdc7db; font-size: 12px; min-width: 0; } +.inventory-session-meta > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.inventory-session-meta .agent-icon { width: 14px; height: 14px; vertical-align: -2px; } +.inventory-session-path { color: #aeb8cb; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 5px; } +.inventory-session-state { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; font-size: 11px; color: #aeb8cb; } +.inventory-status { color: #bdc7db; } +.inventory-status.status-attention { color: var(--yellow); } +.inventory-status.status-failed { color: #ff8b84; } .session-dot.failed { background: var(--red); } -.inventory-action-status { font-size: 11px; color: #ff8b84; margin: 0 12px 8px; overflow-wrap: anywhere; } +.session-role { font-size: 10px; color: var(--blue); border: 1px solid var(--border); border-radius: 4px; padding: 2px 5px; } +.inventory-session-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } +.inventory-session-actions .btn-sm { min-height: 34px; padding: 5px 8px; font-size: 11px; } +.inventory-session-actions .btn-sm:not(.btn-accent):not(.btn-danger) { background: none; border: 1px solid var(--border); color: var(--text-dim); font-weight: 400; } +.inventory-session-actions .btn-sm:not(.btn-accent):not(.btn-danger):hover:not(:disabled) { background: rgba(233, 69, 96, 0.1); border-color: var(--accent); color: var(--accent); } +.inventory-session .session-fork-form { margin-top: 8px; } +.inventory-session .egg-footer .inventory-stop { min-height: 26px; padding: 3px 8px; flex-shrink: 0; } +.inventory-action-status:not(:empty) { font-size: 11px; color: #ff8b84; margin-top: 10px; overflow-wrap: anywhere; } +.inventory-session.renaming .inventory-rename { display: none; } +.inventory-session .session-rename-status:not(:empty) { position: static; margin-top: 8px; } +.tab-copy { display: flex; flex-direction: column; flex: 1; min-width: 0; text-align: left; } +.tab-copy .tab-label { display: block; width: 100%; } +.tab-meta { color: #aeb8cb; font-size: 10px; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-tab.renaming .tab-meta { display: none; } .wd-session-row:focus-within .wd-kill-btn, .wd-session-row:focus .wd-kill-btn { opacity: 1; } .wd-session-state { font-size: 10px; color: #aeb8cb; } .wd-kill-btn:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { cursor: not-allowed; } .box-detail-dialog { max-width: min(460px, calc(100vw - 28px)); min-width: min(360px, calc(100vw - 28px)); max-height: calc(100dvh - 40px); overflow-y: auto; } @media (max-width: 600px) { + .tab-copy { display: none; } + #session-tabs .inventory-group-header { display: none; } .inventory-group-header h4 { flex-basis: 100%; } .inventory-group-header .inventory-acknowledge { min-height: 44px; } + .inventory-filters label:not(.inventory-search) { flex: 1 1 calc(50% - 8px); } + .inventory-filters select { width: 100%; max-width: none; } .inventory-filters .inventory-search { flex-basis: 100%; } - .inventory-filters input { min-height: 44px; font-size: 16px; } - #sessions-list .inventory-session { width: 100%; } - .inventory-session .egg-footer { min-height: 48px; font-size: 14px; } - .inventory-session .box-menu-btn { width: 44px; min-height: 44px; } + .inventory-session-actions .btn-sm { min-height: 44px; padding: 8px 10px; } .wd-session-row { flex-wrap: wrap; } .wd-kill-btn { opacity: 1; min-height: 40px; } .wd-past-row { flex-wrap: wrap; } .wd-past-row .btn-sm { min-height: 40px; } } -@media (hover: none) and (pointer: coarse) { .wd-kill-btn, .box-menu-btn { opacity: 1; pointer-events: auto; } } +@media (hover: none) and (pointer: coarse) { .wd-kill-btn, .session-rename-btn { opacity: 1; pointer-events: auto; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } /* Persistent conversations share the same personal inventory surface. */ diff --git a/web/test/session-inventory.test.js b/web/test/session-inventory.test.js index e5909574..c88d4b31 100644 --- a/web/test/session-inventory.test.js +++ b/web/test/session-inventory.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionIsViewed, sessionResourceKey, sessionProjectRoot, groupSessionInventory, groupSessionsByWing, sessionGroupHeader, sessionExceptionLabel, sessionTabMarkup, sessionCardMarkup, inventorySearchVisible, inventoryCountLabel } from '../src/session-inventory.js'; +import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionIsViewed, sessionResourceKey, sessionProjectRoot, groupSessionInventory, sessionGroupHeader } from '../src/session-inventory.js'; import { sessionRoute, parseSessionRoute } from '../src/session-route.js'; const wing = { wing_id: 'mac', wing_label: 'Personal Mac', hostname: 'mac-mini', online: true, capabilities: ['session.rename.v1'] }; @@ -28,20 +28,6 @@ test('groups use exact wing IDs and cwd roots, not names or basename collisions' assert.equal(groups[3].project, ''); }); -test('Home groups by wing, keeping attention order across that wing\'s projects', () => { - const wings = [wing, { ...wing, wing_id: 'linux' }]; - const row = (id, status, wingId, cwd) => ({ ...session, id, wing_id: wingId, cwd, lifecycle: { status, state_source: 'claude_hook' } }); - // Project /a holds the most urgent session, so its idle session would - // otherwise sit ahead of /b's working one. - const sessions = [row('a-idle', 'idle', 'mac', '/a'), row('a-blocked', 'blocked', 'mac', '/a'), row('b-working', 'working', 'mac', '/b'), - row('linux-working', 'working', 'linux', '/c'), row('c-unknown', 'unknown', 'mac', '/c')]; - const groups = groupSessionsByWing(groupSessionInventory(sessions, wings)); - assert.deepEqual(groups.map(group => group.wingId), ['mac', 'linux']); - assert.deepEqual(groups[0].sessions.map(item => item.id), ['a-blocked', 'b-working', 'a-idle', 'c-unknown']); - assert.deepEqual(groups[1].sessions.map(item => item.id), ['linux-working']); - assert.equal(new Set(groups.map(group => group.key)).size, 2); -}); - test('project grouping uses the nearest known root and respects path boundaries', () => { const known = { ...wing, projects: [{ path: '/repos/app/' }, { path: '/repos/app/nested' }] }; assert.equal(sessionProjectRoot({ cwd: '/repos/app/src' }, known), '/repos/app'); @@ -86,75 +72,6 @@ test('blocked keeps the attention tone offline and group headers escape remote p assert.match(markup, /title="<img> · mac<svg>"/, 'the wing ID stays in the tooltip'); }); -test('the compact Home group header names only the wing, escaped, with no rollup or action', () => { - const hostile = { ...session, wing_id: 'mac', cwd: '/repo/' }], {}, new Set([sessionResourceKey(hostile)])); - const markup = sessionGroupHeader(group, { compact: true }); - assert.doesNotMatch(markup, /