From a22ef4efed02e0bae28def551bc72a44ccdc02c5 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 03:28:23 +0300 Subject: [PATCH] feat(cases): add inline edit and delete actions --- src/web/public/session-ui.js | 125 +++++++++++++++++++++++++++-------- src/web/public/styles.css | 122 ++++++++++++++++++++++++++++++---- test/run-mode-ui.test.ts | 65 +++++++++++++++++- 3 files changed, 269 insertions(+), 43 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index d3cbec61..15628b33 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -161,17 +161,37 @@ Object.assign(CodemanApp.prototype, { const selected = option.name === selectedName; const id = `quickStartCaseOption-${index}`; return ` - +
+ + + + + +
`; }) .join(''); @@ -238,6 +258,20 @@ Object.assign(CodemanApp.prototype, { }); list.addEventListener('mousedown', event => event.preventDefault()); list.addEventListener('click', event => { + const action = event.target.closest?.('[data-case-action]'); + if (action) { + event.preventDefault(); + event.stopPropagation(); + const caseName = action.closest('.case-combobox-row')?.dataset?.case; + if (!caseName) return; + if (action.dataset.caseAction === 'edit') { + this.editCaseFromPicker(caseName); + } else if (action.dataset.caseAction === 'delete') { + this.closeCasePicker(); + this.deleteCase(caseName); + } + return; + } const option = event.target.closest?.('.case-combobox-option'); if (option?.dataset?.case) { this.selectQuickStartCase(option.dataset.case); @@ -1637,6 +1671,23 @@ Object.assign(CodemanApp.prototype, { if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m; }, + editCaseFromPicker(caseName, mobile = false) { + this.selectQuickStartCase(caseName); + if (mobile) this.closeMobileCasePicker(); + + const popover = document.getElementById( + mobile ? 'caseSettingsPopoverMobile' : 'caseSettingsPopover' + ); + // Force the existing toggle onto its open path even if another case's + // settings popover was already visible. + popover?.classList.add('hidden'); + if (mobile) { + this.toggleCaseSettingsMobile(); + } else { + this.toggleCaseSettings(); + } + }, + // ═══════════════════════════════════════════════════════════════ // Create Case Modal // ═══════════════════════════════════════════════════════════════ @@ -2441,25 +2492,41 @@ Object.assign(CodemanApp.prototype, { for (const c of allCases) { const isSelected = c.name === currentCase; html += ` - + + + - + `; } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 67e2a847..9a52019c 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -4053,13 +4053,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { display: none; } +.case-combobox-row { + position: relative; + display: flex; + align-items: center; +} + .case-combobox-option { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; + flex: 1; + min-width: 0; width: 100%; min-height: 34px; - padding: 0.35rem 0.45rem; + padding: 0.35rem 4.2rem 0.35rem 0.45rem; background: transparent; border: 1px solid transparent; border-radius: 6px; @@ -4093,6 +4101,58 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { white-space: nowrap; } +.case-combobox-actions { + position: absolute; + right: 0.3rem; + display: flex; + gap: 0.15rem; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-smooth); +} + +.case-combobox-row:hover .case-combobox-actions, +.case-combobox-row:focus-within .case-combobox-actions { + opacity: 1; + pointer-events: auto; +} + +.case-combobox-action { + display: inline-flex; + width: 28px; + height: 28px; + padding: 0; + align-items: center; + justify-content: center; + color: var(--text-dim); + background: #242a34; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + cursor: pointer; +} + +.case-combobox-action:hover, +.case-combobox-action:focus-visible { + color: var(--text); + background: #303846; +} + +.case-combobox-action-delete:hover, +.case-combobox-action-delete:focus-visible { + color: #f87171; + border-color: rgba(248, 113, 113, 0.45); +} + +.case-combobox-action svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + .case-combobox-empty { padding: 0.85rem 0.75rem; color: var(--text-muted); @@ -5059,23 +5119,20 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .mobile-case-item { display: flex; align-items: center; - gap: 12px; - padding: 14px 20px; + padding: 4px 8px; background: transparent; border: none; color: #e5e5e5; - font-size: 0.95rem; - text-align: left; - cursor: pointer; transition: background 0.1s; width: 100%; } -.mobile-case-item:hover { +.mobile-case-item:hover, +.mobile-case-item:focus-within { background: rgba(255, 255, 255, 0.05); } -.mobile-case-item:active { +.mobile-case-item:has(.mobile-case-item-select:active) { background: rgba(255, 255, 255, 0.1); } @@ -5084,6 +5141,23 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #22c55e; } +.mobile-case-item-select { + display: flex; + min-width: 0; + min-height: 44px; + padding: 8px 4px 8px 12px; + align-items: center; + flex: 1; + gap: 12px; + color: inherit; + background: transparent; + border: 0; + font: inherit; + font-size: 0.95rem; + text-align: left; + cursor: pointer; +} + .mobile-case-item-icon { width: 20px; height: 20px; @@ -5120,21 +5194,43 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { opacity: 1; } -.mobile-case-item-delete { - width: 28px; - height: 28px; +.mobile-case-item-actions { + display: flex; + flex-shrink: 0; + gap: 4px; +} + +.mobile-case-item-action { + width: 36px; + height: 36px; + padding: 0; display: flex; align-items: center; justify-content: center; color: var(--text-dim); - opacity: 0.4; flex-shrink: 0; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; transition: all var(--transition-smooth); } +.mobile-case-item-action svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.mobile-case-item-edit:active { + color: #93c5fd; + background: rgba(59, 130, 246, 0.15); +} + .mobile-case-item-delete:active { - opacity: 1; color: #ef4444; background: rgba(239, 68, 68, 0.15); } diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 0c5b25af..33e2234e 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; function loadRunModeHarness() { const elements: Record = {}; @@ -365,6 +365,69 @@ describe('case selector refresh', () => { expect(app.run).toHaveBeenCalledTimes(1); }); + it('renders distinct edit and delete actions beside every case option', () => { + const elements: Record = { + quickStartCaseSearch: { + setAttribute: vi.fn(), + removeAttribute: vi.fn(), + }, + quickStartCaseList: { + innerHTML: '', + classList: { remove: vi.fn() }, + }, + quickStartCase: { value: 'Alpha' }, + }; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => elements[id] ?? null }, + console, + escapeHtml: (value: string) => value, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.cases = [{ name: 'Alpha' }, { name: 'Beta' }]; + app.renderCasePickerList(); + + expect(elements.quickStartCaseList.innerHTML.match(/data-case-action="edit"/g)).toHaveLength(3); + expect(elements.quickStartCaseList.innerHTML.match(/data-case-action="delete"/g)).toHaveLength(3); + }); + + it('opens the selected case settings directly from an inline edit action', () => { + const elements: Record = { + quickStartCase: { value: 'Alpha' }, + caseSettingsPopover: { + classList: { + add: vi.fn(), + contains: vi.fn(() => true), + remove: vi.fn(), + }, + }, + }; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => elements[id] ?? null }, + console, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.selectQuickStartCase = vi.fn(); + app.toggleCaseSettings = vi.fn(); + + app.editCaseFromPicker('Beta'); + + expect(app.selectQuickStartCase).toHaveBeenCalledWith('Beta'); + expect(elements.caseSettingsPopover.classList.add).toHaveBeenCalledWith('hidden'); + expect(app.toggleCaseSettings).toHaveBeenCalledOnce(); + }); + it('creates remote shell sessions by caseName instead of remote display path', async () => { const elements: Record = { quickStartCase: { value: 'gpu-work' },