From 2b147958574d181674e98888d8b4e5d8e3d7d9df Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:01:29 +0200 Subject: [PATCH 01/25] fix: Windows Unix socket, panel background polling and TV PiP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate agent_socket behind #[cfg(unix)] — compiles clean on Windows - Add onVisibilityChange to PanelInstance + wire through dockview and lazyPanel - ReviewPanel pauses/resumes 5s auto-refresh interval when tab hidden - TasksPanel stops diffRefreshInterval on hide; calls stopDiffRefresh+detailCleanup on dispose - TVPanel: named keydown handler + ResizeObserver ref for proper dispose - TV player.dispose() stops HLS stream and disconnects observer - TV PiP button: explicit requestPictureInPicture with active state and error feedback - pip icon added to icons.ts --- src/app/createWorkspaceView.ts | 5 ++- src/panels/lazyPanel.ts | 1 + src/panels/registry.ts | 1 + src/panels/review/ReviewPanel.ts | 10 ++++-- src/panels/tasks/TasksPanelRuntime.ts | 10 ++++-- src/panels/tv/TVPanel.ts | 44 ++++++++++++++++++++++----- src/panels/tv/definition.ts | 2 +- src/panels/tv/player.ts | 9 ++++++ src/ui/icons.ts | 1 + 9 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/app/createWorkspaceView.ts b/src/app/createWorkspaceView.ts index f14717f..16b78f7 100644 --- a/src/app/createWorkspaceView.ts +++ b/src/app/createWorkspaceView.ts @@ -143,8 +143,11 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt init: params => { if (instance.fit) { params.api.onDidDimensionsChange(() => instance.fit!()) - params.api.onDidVisibilityChange(({ isVisible }) => { if (isVisible) instance.fit!() }) } + params.api.onDidVisibilityChange(({ isVisible }) => { + if (isVisible && instance.fit) instance.fit() + instance.onVisibilityChange?.(isVisible) + }) instance.onTitleChange?.(title => params.api.setTitle(title)) instance.onReady?.({ maximize: () => params.api.maximize(), diff --git a/src/panels/lazyPanel.ts b/src/panels/lazyPanel.ts index b541ec5..8f74f79 100644 --- a/src/panels/lazyPanel.ts +++ b/src/panels/lazyPanel.ts @@ -34,6 +34,7 @@ export function lazyPanel(load: () => Promise): PanelInstance { // Remembers the callback until the panel loads; then re-registers it. onTitleChange: cb => { titleCb = cb; return () => { titleCb = undefined } }, onReady: api => { readyApi = api; inner?.onReady?.(api) }, + onVisibilityChange: (visible) => inner?.onVisibilityChange?.(visible), getCwd: () => inner?.getCwd?.(), } } diff --git a/src/panels/registry.ts b/src/panels/registry.ts index e0f07aa..2e8a9c6 100644 --- a/src/panels/registry.ts +++ b/src/panels/registry.ts @@ -23,6 +23,7 @@ export interface PanelInstance { dispose?: () => void onTitleChange?: (cb: (title: string) => void) => () => void onReady?: (api: PanelApi) => void + onVisibilityChange?: (visible: boolean) => void // Current working directory (terminals report it via OSC 7) getCwd?: () => string | undefined } diff --git a/src/panels/review/ReviewPanel.ts b/src/panels/review/ReviewPanel.ts index 9111322..8b58775 100644 --- a/src/panels/review/ReviewPanel.ts +++ b/src/panels/review/ReviewPanel.ts @@ -175,7 +175,7 @@ function extractFirstJsonObject(text: string): string | null { return null } -export function createReviewPanel(sessionPath?: string): { element: HTMLElement; dispose?: () => void } { +export function createReviewPanel(sessionPath?: string): { element: HTMLElement; dispose?: () => void; onVisibilityChange?: (visible: boolean) => void } { const root = document.createElement('div') root.className = 'review-panel' @@ -188,6 +188,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; let currentPrNumber: number | null = null let intervalId: ReturnType | null = null let autoRefresh = false + let panelVisible = true let existingComments: GhComment[] = [] let loadingBranch = '' let sidebarMode: SidebarMode = 'branches' @@ -1478,7 +1479,7 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; autoRefresh = on autoBtn.classList.toggle('review-icon-btn--active', on) if (intervalId) { clearInterval(intervalId); intervalId = null } - if (on) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) + if (on && panelVisible) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) } const pickRepo = async (): Promise => { @@ -1669,5 +1670,10 @@ export function createReviewPanel(sessionPath?: string): { element: HTMLElement; if (intervalId) clearInterval(intervalId) document.removeEventListener('keydown', handleKeydown) }, + onVisibilityChange: (visible: boolean) => { + panelVisible = visible + if (!visible && intervalId) { clearInterval(intervalId); intervalId = null } + else if (visible && autoRefresh && !intervalId) intervalId = setInterval(() => { if (selectedBranch) loadDiff() }, 5000) + }, } } diff --git a/src/panels/tasks/TasksPanelRuntime.ts b/src/panels/tasks/TasksPanelRuntime.ts index bddf4b7..b44b7e5 100644 --- a/src/panels/tasks/TasksPanelRuntime.ts +++ b/src/panels/tasks/TasksPanelRuntime.ts @@ -37,7 +37,7 @@ import type { AppSettings } from '../../ports/AppSettingsRepository' import { isRunning, parseContainers } from '../../core/docker/containers' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' -export function createTasksPanel(panelId = 'default'): { element: HTMLElement; dispose: () => void } { +export function createTasksPanel(panelId = 'default'): { element: HTMLElement; dispose: () => void; onVisibilityChange: (visible: boolean) => void } { const panelStore = new TaskPanelStore(panelId) const settingsRepository = new TauriAppSettingsRepository() let appSettings: AppSettings = {} @@ -2050,8 +2050,14 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d load() // Dispose all live worktree hubs when the panel/tab closes (persists each). const dispose = (): void => { + stopDiffRefresh() + detailCleanup() for (const panel of worktreeTerminals.values()) panel.dispose() worktreeTerminals.clear() } - return { element: root, dispose } + return { + element: root, + dispose, + onVisibilityChange: (visible: boolean) => { if (!visible) stopDiffRefresh() }, + } } diff --git a/src/panels/tv/TVPanel.ts b/src/panels/tv/TVPanel.ts index 63e4240..b25b367 100644 --- a/src/panels/tv/TVPanel.ts +++ b/src/panels/tv/TVPanel.ts @@ -16,7 +16,7 @@ export function createTVPanel( repo: ChannelRepository, favoritesRepo: FavoritesRepository, worldRepo?: ChannelRepository -): HTMLElement { +): { element: HTMLElement; dispose: () => void } { const root = document.createElement('div') root.className = 'tv-panel' @@ -57,9 +57,14 @@ export function createTVPanel( toggleButton.innerHTML = icon('panel') toggleButton.title = i18nT('tv.showHideChannelList') + const pipButton = document.createElement('button') + pipButton.className = 'tv-btn' + pipButton.innerHTML = icon('pip') + pipButton.title = 'Picture in Picture' + toolbar.append(input, countrySelect, categorySelect, status) if (worldRepo) toolbar.append(worldButton) - toolbar.append(favButton, fullscreenButton, toggleButton) + toolbar.append(favButton, pipButton, fullscreenButton, toggleButton) const main = document.createElement('div') main.className = 'tv-main' @@ -87,7 +92,8 @@ export function createTVPanel( // dockview the panel can take up any fraction). Container queries don't work // here because they would break the position:fixed of cinema mode. const syncWide = (w: number) => main.classList.toggle('wide', w >= 700) - new ResizeObserver(entries => { for (const e of entries) syncWide(e.contentRect.width) }).observe(main) + const resizeObserver = new ResizeObserver(entries => { for (const e of entries) syncWide(e.contentRect.width) }) + resizeObserver.observe(main) let data: ChannelData = { channels: [], countries: [], categories: [] } let allChannels: Channel[] = [] @@ -156,9 +162,8 @@ export function createTVPanel( getCurrentWindow().setFullscreen(on).catch(() => {}) } fullscreenButton.addEventListener('click', () => setCinema(!cinema)) - window.addEventListener('keydown', e => { - if (e.key === 'Escape' && cinema) setCinema(false) - }) + const onEscapeKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && cinema) setCinema(false) } + window.addEventListener('keydown', onEscapeKey) favButton.addEventListener('click', () => { onlyFavorites = !onlyFavorites favButton.classList.toggle('active', onlyFavorites) @@ -179,10 +184,35 @@ export function createTVPanel( } }) + const onEnterPip = () => pipButton.classList.add('active') + const onLeavePip = () => pipButton.classList.remove('active') + document.addEventListener('enterpictureinpicture', onEnterPip) + document.addEventListener('leavepictureinpicture', onLeavePip) + + pipButton.addEventListener('click', async () => { + try { + await player.pip() + } catch { + const prev = pipButton.title + pipButton.title = 'Reproduce un canal primero' + pipButton.style.opacity = '0.4' + setTimeout(() => { pipButton.title = prev; pipButton.style.opacity = '' }, 1500) + } + }) + status.textContent = i18nT('tv.loading') repo.fetchAll() .then(applyData) .catch(err => { status.textContent = i18nT('tv.errorMessage', { message: err.message }) }) - return root + return { + element: root, + dispose: () => { + player.dispose() + resizeObserver.disconnect() + window.removeEventListener('keydown', onEscapeKey) + document.removeEventListener('enterpictureinpicture', onEnterPip) + document.removeEventListener('leavepictureinpicture', onLeavePip) + }, + } } diff --git a/src/panels/tv/definition.ts b/src/panels/tv/definition.ts index e357c91..d34b6d0 100644 --- a/src/panels/tv/definition.ts +++ b/src/panels/tv/definition.ts @@ -14,7 +14,7 @@ export function tvPanelDefinition( title: appT('panelTv'), create: () => lazyPanel(async () => { const { createTVPanel } = await import('./TVPanel') - return { element: createTVPanel(repo, favoritesRepo, worldRepo) } + return createTVPanel(repo, favoritesRepo, worldRepo) }), } } diff --git a/src/panels/tv/player.ts b/src/panels/tv/player.ts index 21a6180..de274b3 100644 --- a/src/panels/tv/player.ts +++ b/src/panels/tv/player.ts @@ -82,6 +82,15 @@ export class HLSPlayer { tracks.forEach((t, i) => { t.mode = i === index ? 'showing' : 'disabled' }) } + async pip(): Promise { + if (this.video.classList.contains('hidden')) throw new Error('pip-not-video') + if (document.pictureInPictureElement === this.video) { + await document.exitPictureInPicture() + } else { + await this.video.requestPictureInPicture() + } + } + stop(): void { if (this.hls) { this.hls.destroy() diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 27bfd82..11c658f 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -11,6 +11,7 @@ const ICONS: Record = { square: '', palette: '', tv: '', + pip: '', terminal: '', expand: '', refresh: '', From 1a81faba88a6cb0d0d96da54dab196cf96481e64 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:12:58 +0200 Subject: [PATCH 02/25] perf: paused TV stream and Docker poll when panel is not visible - HLSPlayer.pause/resume stop segment download (hls.stopLoad) and video decode when the TV panel is hidden; skips pause if video is in PiP so the picture-in-picture keeps playing - TVPanel wires onVisibilityChange to the new pause/resume methods - TasksPanelRuntime also calls detailCleanup() on hide so the Docker 3s poll stops alongside the diff-refresh interval --- src/panels/tasks/TasksPanelRuntime.ts | 2 +- src/panels/tv/TVPanel.ts | 4 ++++ src/panels/tv/player.ts | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/panels/tasks/TasksPanelRuntime.ts b/src/panels/tasks/TasksPanelRuntime.ts index b44b7e5..5b70eda 100644 --- a/src/panels/tasks/TasksPanelRuntime.ts +++ b/src/panels/tasks/TasksPanelRuntime.ts @@ -2058,6 +2058,6 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d return { element: root, dispose, - onVisibilityChange: (visible: boolean) => { if (!visible) stopDiffRefresh() }, + onVisibilityChange: (visible: boolean) => { if (!visible) { stopDiffRefresh(); detailCleanup() } }, } } diff --git a/src/panels/tv/TVPanel.ts b/src/panels/tv/TVPanel.ts index b25b367..359a501 100644 --- a/src/panels/tv/TVPanel.ts +++ b/src/panels/tv/TVPanel.ts @@ -214,5 +214,9 @@ export function createTVPanel( document.removeEventListener('enterpictureinpicture', onEnterPip) document.removeEventListener('leavepictureinpicture', onLeavePip) }, + onVisibilityChange: (visible: boolean) => { + if (!visible && !player.isInPiP) player.pause() + else if (visible) player.resume() + }, } } diff --git a/src/panels/tv/player.ts b/src/panels/tv/player.ts index de274b3..37f0ae1 100644 --- a/src/panels/tv/player.ts +++ b/src/panels/tv/player.ts @@ -82,6 +82,21 @@ export class HLSPlayer { tracks.forEach((t, i) => { t.mode = i === index ? 'showing' : 'disabled' }) } + get isInPiP(): boolean { + return document.pictureInPictureElement === this.video + } + + pause(): void { + this.video.pause() + this.hls?.stopLoad() + } + + resume(): void { + if (this.video.classList.contains('hidden') || !this.video.src) return + this.hls?.startLoad() + this.video.play().catch(() => {}) + } + async pip(): Promise { if (this.video.classList.contains('hidden')) throw new Error('pip-not-video') if (document.pictureInPictureElement === this.video) { From 9f41b5a5daea2a359b932c27ff25933f78d50bf7 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:15:16 +0200 Subject: [PATCH 03/25] perf: auto-resume Docker polling when Tasks panel becomes visible again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Tasks panel is hidden the Docker 3s poll now stops cleanly; when it becomes visible again resumePoll() fires an immediate refresh and restarts the interval — no user interaction required. --- src/panels/tasks/TaskDockerView.ts | 8 +++++--- src/panels/tasks/TasksPanelRuntime.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/panels/tasks/TaskDockerView.ts b/src/panels/tasks/TaskDockerView.ts index f942a64..b0161fd 100644 --- a/src/panels/tasks/TaskDockerView.ts +++ b/src/panels/tasks/TaskDockerView.ts @@ -42,7 +42,7 @@ export interface RecipeApplyResult { interface TaskDockerViewOptions { showDetail: (...nodes: HTMLElement[]) => void resetDetail: () => void - setCleanup: (cleanup: () => void) => void + setCleanup: (cleanup: () => void, resume?: () => void) => void } function iconButton(name: string, title: string, onClick: () => void): HTMLButtonElement { @@ -225,8 +225,10 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { } } void refresh() - const interval = setInterval(refresh, 3000) - options.setCleanup(() => clearInterval(interval)) + let pollInterval: ReturnType | null = setInterval(refresh, 3000) + const stopPoll = (): void => { if (pollInterval !== null) { clearInterval(pollInterval); pollInterval = null } } + const resumePoll = (): void => { stopPoll(); void refresh(); pollInterval = setInterval(refresh, 3000) } + options.setCleanup(stopPoll, resumePoll) options.showDetail(wrap) } diff --git a/src/panels/tasks/TasksPanelRuntime.ts b/src/panels/tasks/TasksPanelRuntime.ts index 5b70eda..cefb239 100644 --- a/src/panels/tasks/TasksPanelRuntime.ts +++ b/src/panels/tasks/TasksPanelRuntime.ts @@ -282,10 +282,11 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d ) return head } + let detailResume: () => void = () => {} const dockerView = createTaskDockerView({ showDetail, - resetDetail: () => { stopDiffRefresh(); detailCleanup(); detailCleanup = () => {} }, - setCleanup: cleanup => { detailCleanup = cleanup }, + resetDetail: () => { stopDiffRefresh(); detailCleanup(); detailCleanup = () => {}; detailResume = () => {} }, + setCleanup: (cleanup, resume = () => {}) => { detailCleanup = cleanup; detailResume = resume }, }) const defaultProjectKey = (repository = repoPath): string => repository.replace(/\/$/, '').split('/').pop() ?? '' @@ -2058,6 +2059,9 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d return { element: root, dispose, - onVisibilityChange: (visible: boolean) => { if (!visible) { stopDiffRefresh(); detailCleanup() } }, + onVisibilityChange: (visible: boolean) => { + if (!visible) { stopDiffRefresh(); detailCleanup() } + else detailResume() + }, } } From 214043a0c604348f918852d086f3930b2b9ba867 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:20:36 +0200 Subject: [PATCH 04/25] perf: reduced xterm scrollback and HLS buffer to lower RAM usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terminal scrollback 10 000 → 2 000 lines (5x less RAM per terminal; 2 000 is still plenty for typical debug sessions) - HLS.js maxBufferLength 30s → 10s, maxMaxBufferLength 600s → 20s (cuts pre-download buffer for TV streams by 3x) --- src/panels/terminal/TerminalPanel.ts | 2 +- src/panels/tv/player.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panels/terminal/TerminalPanel.ts b/src/panels/terminal/TerminalPanel.ts index bcf5bc9..8c6268b 100644 --- a/src/panels/terminal/TerminalPanel.ts +++ b/src/panels/terminal/TerminalPanel.ts @@ -80,7 +80,7 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () // Opaque background: allowTransparency makes the renderer clear-to-transparent // and repaint each frame, which flickers on animated fullscreen TUIs. allowTransparency: false, - scrollback: 10000, + scrollback: 2000, theme: getTheme(getThemeName()), }) diff --git a/src/panels/tv/player.ts b/src/panels/tv/player.ts index 37f0ae1..ce6a018 100644 --- a/src/panels/tv/player.ts +++ b/src/panels/tv/player.ts @@ -66,7 +66,7 @@ export class HLSPlayer { const { default: Hls } = await import('hls.js') const mode = choosePlaybackMode(canPlayNative, Hls.isSupported()) if (mode !== 'hls') { this.onStatus?.('error'); return } - this.hls = new Hls({ lowLatencyMode: false }) + this.hls = new Hls({ lowLatencyMode: false, maxBufferLength: 10, maxMaxBufferLength: 20 }) this.hls.on(Hls.Events.ERROR, (_e, data) => { if (data.fatal) this.onStatus?.('error') }) this.hls.loadSource(url) this.hls.attachMedia(this.video) From 18a6d40a05898e6a11237f1a9b13690e71769c15 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:27:13 +0200 Subject: [PATCH 05/25] fix: hide WebPanel native webview on tab switch; defer panel init when hidden - WebPanel now tracks dockview visibility via onVisibilityChange so the native webview overlay is hidden immediately when another tab is active, preventing it from painting over other panels (visibility:hidden from dockview does not trigger IntersectionObserver) - lazyPanel queues the last onVisibilityChange call and replays it once the inner module finishes loading, so TV/terminal panels that are hidden before their import() resolves don't start streaming/running --- src/panels/lazyPanel.ts | 9 ++++++++- src/panels/web/WebPanel.ts | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/panels/lazyPanel.ts b/src/panels/lazyPanel.ts index 8f74f79..6d0c40b 100644 --- a/src/panels/lazyPanel.ts +++ b/src/panels/lazyPanel.ts @@ -12,6 +12,7 @@ export function lazyPanel(load: () => Promise): PanelInstance { let disposed = false let titleCb: ((title: string) => void) | undefined let readyApi: PanelApi | undefined + let pendingVisibility: boolean | undefined load().then(instance => { if (disposed) { instance.dispose?.(); return } @@ -19,6 +20,9 @@ export function lazyPanel(load: () => Promise): PanelInstance { element.replaceChildren(instance.element) if (titleCb) instance.onTitleChange?.(titleCb) if (readyApi) instance.onReady?.(readyApi) + // If the panel was hidden while its module was still loading, apply the + // last known visibility now so it doesn't start active while off-screen. + if (pendingVisibility === false) instance.onVisibilityChange?.(false) instance.fit?.() }).catch(error => { if (disposed) return @@ -34,7 +38,10 @@ export function lazyPanel(load: () => Promise): PanelInstance { // Remembers the callback until the panel loads; then re-registers it. onTitleChange: cb => { titleCb = cb; return () => { titleCb = undefined } }, onReady: api => { readyApi = api; inner?.onReady?.(api) }, - onVisibilityChange: (visible) => inner?.onVisibilityChange?.(visible), + onVisibilityChange: (visible) => { + pendingVisibility = visible + inner?.onVisibilityChange?.(visible) + }, getCwd: () => inner?.getCwd?.(), } } diff --git a/src/panels/web/WebPanel.ts b/src/panels/web/WebPanel.ts index 1f9d087..723b789 100644 --- a/src/panels/web/WebPanel.ts +++ b/src/panels/web/WebPanel.ts @@ -273,10 +273,11 @@ export function createWebPanel() { let intersecting = true let suppressed = false let menuOpen = false + let dockviewVisible = true const reevaluate = () => { if (!currentUrl) return const style = getComputedStyle(content) - const visible = isWebviewVisible({ + const visible = dockviewVisible && isWebviewVisible({ intersecting, visibility: style.visibility, display: style.display, @@ -322,6 +323,7 @@ export function createWebPanel() { element: root, fit: () => updateBounds(), focus: () => input.focus(), + onVisibilityChange: (visible: boolean) => { dockviewVisible = visible; reevaluate() }, dispose: () => { resizeObserver.disconnect() intersectionObserver.disconnect() From 76b5ed45aadc9a6986c03313a01666ed5675444e Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:35:09 +0200 Subject: [PATCH 06/25] feat: added live RAM display to agent status bar --- src-tauri/Cargo.lock | 59 ++++++++++++++++++++++++++++----- src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 3 ++ src-tauri/src/system_metrics.rs | 26 +++++++++++++++ src/i18n/en.json | 2 ++ src/i18n/es.json | 2 ++ src/styles.css | 27 ++++++++++++++- src/ui/agentStatusBar.ts | 53 +++++++++++++++++++++++++---- tests/ui/agentStatusBar.test.ts | 17 ++++++++++ 9 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 src-tauri/src/system_metrics.rs create mode 100644 tests/ui/agentStatusBar.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d84b561..0396a7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -239,6 +239,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -2287,6 +2288,15 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3909,6 +3919,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "windows 0.52.0", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3969,7 +3993,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4046,7 +4070,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4219,7 +4243,7 @@ dependencies = [ "uuid", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -4245,7 +4269,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4270,7 +4294,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -5115,7 +5139,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5139,7 +5163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.19", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5189,6 +5213,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5211,6 +5245,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5670,7 +5713,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d20934e..158ebc5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ time = { version = "0.3", features = ["formatting"] } uuid = { version = "1", features = ["v4"] } libc = "0.2" futures = "0.3" +sysinfo = { version = "0.30", default-features = false } tauri-plugin-wdio-webdriver = { version = "1", optional = true } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c7304f6..fc8e4ae 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -23,6 +23,7 @@ mod pty; mod review; mod scripts; mod settings; +mod system_metrics; mod traffic_lights; mod vault; mod web_panel; @@ -211,12 +212,14 @@ fn main() { .manage(agent::AgentManager::default()) .manage(web_panel::WebPanelState::default()) .manage(docker::LogStreams::default()) + .manage(system_metrics::SystemMetricsState::default()) .manage(vault::VaultState(std::sync::Mutex::new(None))) .invoke_handler(tauri::generate_handler![ http_get, http_request, http_fetch_base64, app_identifier, + system_metrics::app_memory_usage, agent::start_agent, agent::cancel_agent, agent_sessions::agent_codex_clear_lock, diff --git a/src-tauri/src/system_metrics.rs b/src-tauri/src/system_metrics.rs new file mode 100644 index 0000000..ff9121f --- /dev/null +++ b/src-tauri/src/system_metrics.rs @@ -0,0 +1,26 @@ +use std::sync::Mutex; + +use sysinfo::{Pid, System}; +use tauri::State; + +pub struct SystemMetricsState(pub Mutex); + +impl Default for SystemMetricsState { + fn default() -> Self { + Self(Mutex::new(System::new())) + } +} + +// Resident memory used by Bento's host process. Terminal commands and AI agents +// are intentionally excluded: they are external programs and summing their RSS +// also counts shared pages more than once, producing a misleading app total. +#[tauri::command] +pub fn app_memory_usage(state: State<'_, SystemMetricsState>) -> Result { + let mut system = state.0.lock().map_err(|e| e.to_string())?; + let pid = Pid::from_u32(std::process::id()); + system.refresh_process(pid); + system + .process(pid) + .map(|process| process.memory()) + .ok_or_else(|| "Could not read Bento memory usage".to_string()) +} diff --git a/src/i18n/en.json b/src/i18n/en.json index c9c4741..32f9b1c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -27,6 +27,8 @@ "agentWaiting": "waiting", "agentWorking": "working", "agentIdle": "idle", + "ramUsage": "RAM used by Bento", + "ramUnavailable": "RAM usage unavailable", "resize": "Resize", "top": "top", "bottom": "bottom", diff --git a/src/i18n/es.json b/src/i18n/es.json index 9311c96..c832f58 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -27,6 +27,8 @@ "agentWaiting": "esperando", "agentWorking": "trabajando", "agentIdle": "inactivo", + "ramUsage": "Memoria RAM usada por Bento", + "ramUnavailable": "Uso de memoria RAM no disponible", "resize": "Redimensionar", "top": "arriba", "bottom": "abajo", diff --git a/src/styles.css b/src/styles.css index da3fa0a..6e2a18e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -199,11 +199,36 @@ html, body, #app { border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent); background: color-mix(in srgb, #050608 88%, transparent); color: var(--fg-dim); - overflow-x: auto; + overflow: hidden; flex-shrink: 0; -webkit-app-region: drag; } .agent-status-bar.hidden { display: none; } +.agent-status-list { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} +.agent-status-list::-webkit-scrollbar { display: none; } +.app-memory-status { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + margin-left: auto; + padding: 2px 7px; + border-left: 1px solid var(--border); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + -webkit-app-region: no-drag; +} +.app-memory-label { color: var(--fg-dim); font-weight: 600; } +.app-memory-value { color: var(--fg); min-width: 48px; text-align: right; } .agent-status-chip { -webkit-app-region: no-drag; display: inline-flex; diff --git a/src/ui/agentStatusBar.ts b/src/ui/agentStatusBar.ts index a28ff9d..fcac0de 100644 --- a/src/ui/agentStatusBar.ts +++ b/src/ui/agentStatusBar.ts @@ -1,5 +1,6 @@ import { AGENT_DOCK_EVENT, savedAgentDockEntries, type AgentDockEntry } from '../core/terminal/agentDockState' import { appT } from '../core/i18n' +import { invoke } from '@tauri-apps/api/core' interface AgentStatusBarOptions { onOpenAgents: () => void @@ -22,17 +23,32 @@ const statusText = (entry: AgentDockEntry): string => { return appT('agentIdle') } +export const formatMemoryUsage = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes < 0) return '—' + const mb = bytes / (1024 * 1024) + if (mb < 1024) return `${Math.round(mb)} MB` + return `${(mb / 1024).toFixed(1)} GB` +} + export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { element: HTMLElement; dispose: () => void } { const element = document.createElement('div') - element.className = 'agent-status-bar hidden' + element.className = 'agent-status-bar' element.setAttribute('role', 'status') let entries = savedAgentDockEntries() + const agents = document.createElement('div') + agents.className = 'agent-status-list' + + const memory = document.createElement('div') + memory.className = 'app-memory-status' + memory.setAttribute('aria-label', appT('ramUsage')) + memory.title = appT('ramUsage') + memory.innerHTML = 'RAM' + const memoryValue = memory.querySelector('.app-memory-value')! + const render = (): void => { - element.replaceChildren() - element.classList.toggle('hidden', entries.length === 0) - if (entries.length === 0) return + agents.replaceChildren() for (const entry of entries) { const button = document.createElement('button') @@ -56,7 +72,23 @@ export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { }) button.append(dot, bar, label, state) - element.appendChild(button) + agents.appendChild(button) + } + } + + let memoryRequestActive = false + const refreshMemory = async (): Promise => { + if (memoryRequestActive || document.hidden) return + memoryRequestActive = true + try { + const bytes = await invoke('app_memory_usage') + memoryValue.textContent = formatMemoryUsage(bytes) + memory.title = `${appT('ramUsage')}: ${formatMemoryUsage(bytes)}` + } catch { + memoryValue.textContent = '—' + memory.title = appT('ramUnavailable') + } finally { + memoryRequestActive = false } } @@ -65,10 +97,19 @@ export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { render() } window.addEventListener(AGENT_DOCK_EVENT, onDock) + const onVisibilityChange = (): void => { if (!document.hidden) void refreshMemory() } + document.addEventListener('visibilitychange', onVisibilityChange) + const memoryTimer = window.setInterval(() => void refreshMemory(), 3000) + element.replaceChildren(agents, memory) render() + void refreshMemory() return { element, - dispose: () => window.removeEventListener(AGENT_DOCK_EVENT, onDock), + dispose: () => { + window.removeEventListener(AGENT_DOCK_EVENT, onDock) + document.removeEventListener('visibilitychange', onVisibilityChange) + window.clearInterval(memoryTimer) + }, } } diff --git a/tests/ui/agentStatusBar.test.ts b/tests/ui/agentStatusBar.test.ts new file mode 100644 index 0000000..4e3f160 --- /dev/null +++ b/tests/ui/agentStatusBar.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { formatMemoryUsage } from '../../src/ui/agentStatusBar' + +describe('formatMemoryUsage', () => { + it('formats bytes as whole megabytes', () => { + expect(formatMemoryUsage(256 * 1024 * 1024)).toBe('256 MB') + }) + + it('uses gigabytes for larger footprints', () => { + expect(formatMemoryUsage(1536 * 1024 * 1024)).toBe('1.5 GB') + }) + + it('rejects invalid readings', () => { + expect(formatMemoryUsage(Number.NaN)).toBe('—') + expect(formatMemoryUsage(-1)).toBe('—') + }) +}) From 9e0b4f8a124300463ee1a323ec469505285a2ba6 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:49:36 +0200 Subject: [PATCH 07/25] perf: improved RAM monitor to include process tree with native OS metrics --- src-tauri/src/system_metrics.rs | 170 ++++++++++++++++++++++++++++++-- src/i18n/en.json | 2 +- src/i18n/es.json | 2 +- 3 files changed, 162 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/system_metrics.rs b/src-tauri/src/system_metrics.rs index ff9121f..45e1665 100644 --- a/src-tauri/src/system_metrics.rs +++ b/src-tauri/src/system_metrics.rs @@ -1,6 +1,7 @@ +use std::collections::HashSet; use std::sync::Mutex; -use sysinfo::{Pid, System}; +use sysinfo::{Pid, Process, System}; use tauri::State; pub struct SystemMetricsState(pub Mutex); @@ -11,16 +12,165 @@ impl Default for SystemMetricsState { } } -// Resident memory used by Bento's host process. Terminal commands and AI agents -// are intentionally excluded: they are external programs and summing their RSS -// also counts shared pages more than once, producing a misleading app total. +fn app_processes(system: &System, root: Pid) -> HashSet { + let mut result = HashSet::from([root]); + loop { + let before = result.len(); + for (pid, process) in system.processes() { + if process + .parent() + .is_some_and(|parent| result.contains(&parent)) + { + result.insert(*pid); + } + } + if result.len() == before { + return result; + } + } +} + +#[cfg(target_os = "macos")] +fn physical_memory(pid: Pid, fallback: &Process) -> u64 { + use std::ffi::c_void; + + // rusage_info_v2 is a UUID followed by 18 u64 fields. phys_footprint is the + // eighth field and is the same footprint metric used by Apple's memory tools. + #[repr(C)] + struct RusageInfoV2 { + uuid: [u8; 16], + values: [u64; 18], + } + unsafe extern "C" { + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut c_void) -> i32; + } + + let mut info = RusageInfoV2 { + uuid: [0; 16], + values: [0; 18], + }; + // SAFETY: info has the C layout and complete size required by RUSAGE_INFO_V2. + let ok = unsafe { + proc_pid_rusage( + pid.as_u32() as i32, + 2, + (&mut info as *mut RusageInfoV2).cast(), + ) + } == 0; + if ok { + info.values[7] + } else { + fallback.memory() + } +} + +#[cfg(target_os = "linux")] +fn physical_memory(pid: Pid, fallback: &Process) -> u64 { + // PSS charges each process only its proportional share of common pages, so + // adding a process tree does not multiply shared libraries/WebView mappings. + let path = format!("/proc/{}/smaps_rollup", pid.as_u32()); + std::fs::read_to_string(path) + .ok() + .and_then(|contents| { + contents.lines().find_map(|line| { + let value = line + .strip_prefix("Pss:")? + .trim() + .split_whitespace() + .next()?; + value.parse::().ok().map(|kb| kb * 1024) + }) + }) + .unwrap_or_else(|| fallback.memory()) +} + +#[cfg(target_os = "windows")] +fn physical_memory(pid: Pid, fallback: &Process) -> u64 { + use std::ffi::c_void; + use std::mem::{size_of, zeroed}; + + type Handle = *mut c_void; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + + #[repr(C)] + struct ProcessMemoryCountersEx2 { + cb: u32, + page_fault_count: u32, + peak_working_set_size: usize, + working_set_size: usize, + quota_peak_paged_pool_usage: usize, + quota_paged_pool_usage: usize, + quota_peak_non_paged_pool_usage: usize, + quota_non_paged_pool_usage: usize, + pagefile_usage: usize, + peak_pagefile_usage: usize, + private_usage: usize, + private_working_set_size: usize, + shared_commit_usage: u64, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> Handle; + fn CloseHandle(handle: Handle) -> i32; + fn K32GetProcessMemoryInfo(handle: Handle, counters: *mut c_void, size: u32) -> i32; + } + + // SAFETY: the handle is closed on every successful OpenProcess path and the + // output buffer has the exact PROCESS_MEMORY_COUNTERS_EX2 C layout. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid.as_u32()); + if handle.is_null() { + return fallback.virtual_memory(); + } + let mut counters: ProcessMemoryCountersEx2 = zeroed(); + counters.cb = size_of::() as u32; + let ok = K32GetProcessMemoryInfo( + handle, + (&mut counters as *mut ProcessMemoryCountersEx2).cast(), + counters.cb, + ) != 0; + CloseHandle(handle); + if ok { + counters.private_working_set_size as u64 + } else { + fallback.virtual_memory() + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn physical_memory(_pid: Pid, fallback: &Process) -> u64 { + fallback.memory() +} + +// Current physical footprint of Bento plus its WebViews, terminals and agents. +// Each supported OS uses a non-duplicating native metric instead of adding RSS. #[tauri::command] pub fn app_memory_usage(state: State<'_, SystemMetricsState>) -> Result { let mut system = state.0.lock().map_err(|e| e.to_string())?; - let pid = Pid::from_u32(std::process::id()); - system.refresh_process(pid); - system - .process(pid) - .map(|process| process.memory()) - .ok_or_else(|| "Could not read Bento memory usage".to_string()) + system.refresh_processes(); + let root = Pid::from_u32(std::process::id()); + let total = app_processes(&system, root) + .into_iter() + .filter_map(|pid| { + system + .process(pid) + .map(|process| physical_memory(pid, process)) + }) + .sum(); + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_metric_reads_current_process() { + let mut system = System::new(); + let pid = Pid::from_u32(std::process::id()); + system.refresh_process(pid); + let process = system.process(pid).expect("current process"); + assert!(physical_memory(pid, process) > 0); + } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 32f9b1c..3d495cb 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -27,7 +27,7 @@ "agentWaiting": "waiting", "agentWorking": "working", "agentIdle": "idle", - "ramUsage": "RAM used by Bento", + "ramUsage": "Physical RAM used by Bento and its processes", "ramUnavailable": "RAM usage unavailable", "resize": "Resize", "top": "top", diff --git a/src/i18n/es.json b/src/i18n/es.json index c832f58..611a019 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -27,7 +27,7 @@ "agentWaiting": "esperando", "agentWorking": "trabajando", "agentIdle": "inactivo", - "ramUsage": "Memoria RAM usada por Bento", + "ramUsage": "RAM física usada por Bento y sus procesos", "ramUnavailable": "Uso de memoria RAM no disponible", "resize": "Redimensionar", "top": "arriba", From 85930cf6a44f99bc05c2ea196908eeaaf767051a Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:54:37 +0200 Subject: [PATCH 08/25] perf: idle-aware RAM polling and CSS containment on panel roots --- src/styles.css | 2 ++ src/ui/agentStatusBar.ts | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/styles.css b/src/styles.css index 6e2a18e..ffc0bdd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -333,6 +333,7 @@ html, body, #app { .session-instance { position: absolute; inset: 0; + contain: layout style paint; background: radial-gradient(60% 50% at 100% 0%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 65%), radial-gradient(50% 40% at 0% 100%, color-mix(in srgb, var(--accent-2) 10%, transparent), transparent 60%), @@ -505,6 +506,7 @@ html, body, #app { .panel-lazy { width: 100%; height: 100%; + contain: layout style paint; } .panel-load-error { padding: 16px; color: #f7768e; white-space: pre-wrap; overflow: auto; } diff --git a/src/ui/agentStatusBar.ts b/src/ui/agentStatusBar.ts index fcac0de..f6cb694 100644 --- a/src/ui/agentStatusBar.ts +++ b/src/ui/agentStatusBar.ts @@ -97,19 +97,35 @@ export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { render() } window.addEventListener(AGENT_DOCK_EVENT, onDock) - const onVisibilityChange = (): void => { if (!document.hidden) void refreshMemory() } + + // Schedule the next RAM read during idle time (avoids contending with frame + // rendering), but with a hard deadline so it still fires under sustained load. + let idleHandle: ReturnType | number | undefined + const scheduleRefresh = (): void => { + idleHandle = window.setTimeout(() => { + if (typeof requestIdleCallback !== 'undefined') { + idleHandle = requestIdleCallback(() => void refreshMemory().finally(scheduleRefresh), { timeout: 2000 }) + } else { + void refreshMemory().finally(scheduleRefresh) + } + }, 3000) + } + + const onVisibilityChange = (): void => { if (!document.hidden) void refreshMemory().finally(scheduleRefresh) } document.addEventListener('visibilitychange', onVisibilityChange) - const memoryTimer = window.setInterval(() => void refreshMemory(), 3000) element.replaceChildren(agents, memory) render() - void refreshMemory() + void refreshMemory().finally(scheduleRefresh) return { element, dispose: () => { window.removeEventListener(AGENT_DOCK_EVENT, onDock) document.removeEventListener('visibilitychange', onVisibilityChange) - window.clearInterval(memoryTimer) + if (typeof idleHandle === 'number') { + clearTimeout(idleHandle) + if (typeof cancelIdleCallback !== 'undefined') cancelIdleCallback(idleHandle) + } }, } } From 5fcba7e8a3398f4108e1d72fd1ca2e0ee854d38e Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 01:57:07 +0200 Subject: [PATCH 09/25] perf: will-change hints on animated sidebars and AI modal --- src/styles.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index ffc0bdd..28ff326 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1748,6 +1748,7 @@ html, body, #app { border-right: 1px solid var(--border); transition: width 0.18s ease; min-width: 32px; + will-change: width; } .cs-sidebar.collapsed { width: 32px !important; min-width: 32px; } @@ -1822,7 +1823,7 @@ html, body, #app { transition: opacity 0.12s; } .cs-sidebar-toggle:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 10%, transparent); } -.cs-sidebar-toggle svg { width: 13px; height: 13px; transition: transform 0.18s; } +.cs-sidebar-toggle svg { width: 13px; height: 13px; transition: transform 0.18s; will-change: transform; } .cs-sidebar.collapsed .cs-sidebar-toggle svg { transform: rotate(180deg); } .cs-sidebar-list { flex: 1; overflow-y: auto; min-height: 0; } @@ -1865,6 +1866,7 @@ html, body, #app { background: color-mix(in srgb, var(--bg) 85%, var(--surface)); overflow: hidden; transition: width 0.2s ease; + will-change: width; } .agents-sidebar.collapsed { @@ -3758,6 +3760,7 @@ select.db-cell-input { appearance: auto; cursor: pointer; } backdrop-filter: blur(24px) saturate(160%); transition: width 0.16s ease; transform: translate(var(--ai-drag-x, 0px), var(--ai-drag-y, 0px)); + will-change: transform, width; } .ai-modal.hidden { display: none; } .ai-agent-select.hidden { display: none; } From 9c3a681e88990dd7c59ed7841b350c6a29aabcf8 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 02:02:08 +0200 Subject: [PATCH 10/25] perf: cache process tree to avoid full scan on every RAM poll; contain dv-content-container cells --- src-tauri/src/system_metrics.rs | 114 +++++++++++++++++++++++++++++--- src/styles.css | 4 ++ 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/system_metrics.rs b/src-tauri/src/system_metrics.rs index 45e1665..7899ab9 100644 --- a/src-tauri/src/system_metrics.rs +++ b/src-tauri/src/system_metrics.rs @@ -4,15 +4,47 @@ use std::sync::Mutex; use sysinfo::{Pid, Process, System}; use tauri::State; -pub struct SystemMetricsState(pub Mutex); +struct MetricsState { + system: System, + // PIDs in Bento's process group, rebuilt every TREE_REFRESH_EVERY calls so + // new terminals/agents are picked up without scanning all processes each time. + cached_pids: HashSet, + calls_since_tree_refresh: u8, +} + +const TREE_REFRESH_EVERY: u8 = 5; // full scan every ~15 s (at 3 s poll interval) + +pub struct SystemMetricsState(pub Mutex); impl Default for SystemMetricsState { fn default() -> Self { - Self(Mutex::new(System::new())) + Self(Mutex::new(MetricsState { + system: System::new(), + cached_pids: HashSet::new(), + calls_since_tree_refresh: TREE_REFRESH_EVERY, // force scan on first call + })) } } fn app_processes(system: &System, root: Pid) -> HashSet { + // WebKit XPC services are re-parented to launchd on macOS, so a normal + // parent/child walk misses most of Bento's UI memory. Resource coalitions + // are the kernel grouping that keeps an app and those services together. + #[cfg(target_os = "macos")] + if let Some(root_coalition) = resource_coalition_id(root) { + let members: HashSet = system + .processes() + .keys() + .copied() + .filter(|pid| resource_coalition_id(*pid) == Some(root_coalition)) + .collect(); + if !members.is_empty() { + return members; + } + } + + // Linux and Windows keep the terminal/agent process tree attached to the + // host process. This is also the safe fallback if coalition lookup fails. let mut result = HashSet::from([root]); loop { let before = result.len(); @@ -30,6 +62,44 @@ fn app_processes(system: &System, root: Pid) -> HashSet { } } +#[cfg(target_os = "macos")] +fn resource_coalition_id(pid: Pid) -> Option { + use std::ffi::c_void; + + const PROC_PIDCOALITIONINFO: i32 = 20; + #[repr(C)] + struct ProcPidCoalitionInfo { + // RESOURCE and JETSAM coalition IDs, followed by three reserved fields. + coalition_id: [u64; 2], + reserved: [u64; 3], + } + unsafe extern "C" { + fn proc_pidinfo( + pid: i32, + flavor: i32, + arg: u64, + buffer: *mut c_void, + buffer_size: i32, + ) -> i32; + } + + let mut info = ProcPidCoalitionInfo { + coalition_id: [0; 2], + reserved: [0; 3], + }; + // SAFETY: info matches proc_pidcoalitioninfo's C layout and buffer size. + let read = unsafe { + proc_pidinfo( + pid.as_u32() as i32, + PROC_PIDCOALITIONINFO, + 0, + (&mut info as *mut ProcPidCoalitionInfo).cast(), + std::mem::size_of::() as i32, + ) + }; + (read == std::mem::size_of::() as i32).then_some(info.coalition_id[0]) +} + #[cfg(target_os = "macos")] fn physical_memory(pid: Pid, fallback: &Process) -> u64 { use std::ffi::c_void; @@ -145,15 +215,28 @@ fn physical_memory(_pid: Pid, fallback: &Process) -> u64 { // Current physical footprint of Bento plus its WebViews, terminals and agents. // Each supported OS uses a non-duplicating native metric instead of adding RSS. +// The process tree is rebuilt every TREE_REFRESH_EVERY calls; between rebuilds +// only the known PIDs are refreshed, avoiding a full system-wide scan. #[tauri::command] pub fn app_memory_usage(state: State<'_, SystemMetricsState>) -> Result { - let mut system = state.0.lock().map_err(|e| e.to_string())?; - system.refresh_processes(); + let mut ms = state.0.lock().map_err(|e| e.to_string())?; let root = Pid::from_u32(std::process::id()); - let total = app_processes(&system, root) - .into_iter() - .filter_map(|pid| { - system + + if ms.calls_since_tree_refresh >= TREE_REFRESH_EVERY || ms.cached_pids.is_empty() { + ms.system.refresh_processes(); + ms.cached_pids = app_processes(&ms.system, root); + ms.calls_since_tree_refresh = 0; + } else { + for &pid in &ms.cached_pids { + ms.system.refresh_process(pid); + } + ms.calls_since_tree_refresh += 1; + } + + let total = ms.cached_pids + .iter() + .filter_map(|&pid| { + ms.system .process(pid) .map(|process| physical_memory(pid, process)) }) @@ -173,4 +256,19 @@ mod tests { let process = system.process(pid).expect("current process"); assert!(physical_memory(pid, process) > 0); } + + #[test] + fn tree_refresh_resets_after_threshold() { + let state = SystemMetricsState::default(); + let ms = state.0.lock().unwrap(); + assert_eq!(ms.calls_since_tree_refresh, TREE_REFRESH_EVERY); + assert!(ms.cached_pids.is_empty()); + } + + #[cfg(target_os = "macos")] + #[test] + fn native_metric_reads_current_resource_coalition() { + let pid = Pid::from_u32(std::process::id()); + assert!(resource_coalition_id(pid).is_some()); + } } diff --git a/src/styles.css b/src/styles.css index 28ff326..e5ceda6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2377,6 +2377,10 @@ html, body, #app { max-width: 100%; } +.dv-content-container > * { + contain: layout style paint; +} + /* ── Panel launcher (inside the hover title strip) ──────────────────────────── A horizontal toolbar of panel icons living in the top strip. It reveals with the strip (fade handled by the .session-bar rules above), so the panel dock From f20742197339cc13f9b9cbdd6093b63e0815473b Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 02:10:14 +0200 Subject: [PATCH 11/25] fix: kill full PTY process tree on terminal close and app quit --- src-tauri/src/main.rs | 3 + src-tauri/src/pty.rs | 156 ++++++++++++++++++++++++++++++-- src-tauri/src/system_metrics.rs | 70 +++++++++----- src/panels/tv/TVPanel.ts | 3 +- 4 files changed, 200 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc8e4ae..c61101d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -189,6 +189,7 @@ fn main() { install_menu(app)?; if let Some(window) = app.get_webview_window("main") { let manager = app.state::().inner().clone(); + let pty_manager = app.state::>().inner().clone(); let closing = Arc::new(AtomicBool::new(false)); let close_window = window.clone(); window.clone().on_window_event(move |event| { @@ -198,9 +199,11 @@ fn main() { } api.prevent_close(); let manager = manager.clone(); + let pty_manager = pty_manager.clone(); let window = close_window.clone(); tauri::async_runtime::spawn(async move { agent::cancel_all(&manager).await; + pty::kill_all(&pty_manager); let _ = window.close(); }); } diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index b427e36..57f6ef0 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -2,11 +2,13 @@ use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; +use sysinfo::{Pid, System}; use tauri::{AppHandle, Emitter}; struct PtyInstance { writer: Box, master: Box, + child: Box, } #[derive(Default)] @@ -14,6 +16,99 @@ pub struct PtyManager { instances: Mutex>, } +#[cfg(unix)] +fn terminate_process_tree(root: u32) { + let session = Pid::from_u32(root); + let members = |system: &System| -> Vec { + system + .processes() + .iter() + .filter_map(|(pid, process)| { + (process.session_id() == Some(session)).then_some(pid.as_u32() as i32) + }) + .collect() + }; + + let system = System::new_all(); + for pid in members(&system) { + // SAFETY: the PID comes from the current system process snapshot. + unsafe { libc::kill(pid, libc::SIGTERM) }; + } + std::thread::sleep(std::time::Duration::from_millis(150)); + let system = System::new_all(); + for pid in members(&system) { + // Agents may handle/ignore SIGHUP and SIGTERM; SIGKILL guarantees that + // closing their owning terminal actually releases their memory. + unsafe { libc::kill(pid, libc::SIGKILL) }; + } +} + +#[cfg(windows)] +fn terminate_process_tree(root: u32) { + use std::ffi::c_void; + + type Handle = *mut c_void; + const PROCESS_TERMINATE: u32 = 0x0001; + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> Handle; + fn TerminateProcess(process: Handle, exit_code: u32) -> i32; + fn CloseHandle(handle: Handle) -> i32; + } + + let system = System::new_all(); + let mut tree = vec![Pid::from_u32(root)]; + loop { + let before = tree.len(); + for (pid, process) in system.processes() { + if process + .parent() + .is_some_and(|parent| tree.contains(&parent)) + && !tree.contains(pid) + { + tree.push(*pid); + } + } + if tree.len() == before { + break; + } + } + + // Children first so they cannot survive after their parent disappears. + for pid in tree.into_iter().rev() { + // SAFETY: handles are checked and closed, and PROCESS_TERMINATE is the + // minimum access required for this operation. + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid.as_u32()); + if !handle.is_null() { + TerminateProcess(handle, 1); + CloseHandle(handle); + } + } + } +} + +fn terminate_instance(mut instance: PtyInstance) { + if let Some(pid) = instance.child.process_id() { + terminate_process_tree(pid); + } + let _ = instance.child.kill(); + let _ = instance.child.wait(); +} + +pub fn kill_all(manager: &PtyManager) { + let instances: Vec = manager + .instances + .lock() + .unwrap() + .drain() + .map(|(_, instance)| instance) + .collect(); + for instance in instances { + terminate_instance(instance); + } +} + fn dirs_home() -> Option { std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) @@ -70,6 +165,38 @@ mod tests { assert_eq!(drain_utf8(&mut p), "a"); assert_eq!(p, vec![0xE2, 0x94]); } + + #[cfg(unix)] + #[test] + fn terminating_a_pty_kills_its_whole_session() { + use super::{terminate_instance, PtyInstance}; + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let mut command = CommandBuilder::new("/bin/sh"); + command.args(["-c", "sleep 30 & wait"]); + let child = pair.slave.spawn_command(command).unwrap(); + let pid = child.process_id().unwrap(); + let writer = pair.master.take_writer().unwrap(); + + terminate_instance(PtyInstance { + writer, + master: pair.master, + child, + }); + + // Signal 0 only checks existence; ESRCH proves the session leader was + // killed and reaped rather than merely detached from the UI. + let exists = unsafe { libc::kill(pid as i32, 0) } == 0; + assert!(!exists, "PTY process {pid} survived termination"); + } } #[tauri::command] @@ -141,7 +268,7 @@ pub fn pty_spawn( cmd.cwd(dir); } - let _child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; + let child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; let writer = pair.master.take_writer().map_err(|e| e.to_string())?; let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?; @@ -149,6 +276,16 @@ pub fn pty_spawn( let id_clone = id.clone(); let app_clone = app.clone(); + state.instances.lock().unwrap().insert( + id.clone(), + PtyInstance { + writer, + master: pair.master, + child, + }, + ); + + let manager = state.inner().clone(); std::thread::spawn(move || { let mut buf = [0u8; 4096]; // Holds bytes of a multi-byte char split across reads (see drain_utf8). @@ -167,17 +304,13 @@ pub fn pty_spawn( } // EOF: the shell exited (e.g. the user typed `exit`); tell the frontend // so it can close the panel instead of leaving a dead terminal. + let instance = manager.instances.lock().unwrap().remove(&id_clone); + if let Some(instance) = instance { + terminate_instance(instance); + } let _ = app_clone.emit(&format!("pty-exit-{}", id_clone), ()); }); - state.instances.lock().unwrap().insert( - id, - PtyInstance { - writer, - master: pair.master, - }, - ); - Ok(()) } @@ -217,6 +350,9 @@ pub fn pty_resize( #[tauri::command] pub fn pty_kill(id: String, state: tauri::State>) -> Result<(), String> { - state.instances.lock().unwrap().remove(&id); + let instance = state.instances.lock().unwrap().remove(&id); + if let Some(instance) = instance { + terminate_instance(instance); + } Ok(()) } diff --git a/src-tauri/src/system_metrics.rs b/src-tauri/src/system_metrics.rs index 7899ab9..9559cef 100644 --- a/src-tauri/src/system_metrics.rs +++ b/src-tauri/src/system_metrics.rs @@ -14,7 +14,7 @@ struct MetricsState { const TREE_REFRESH_EVERY: u8 = 5; // full scan every ~15 s (at 3 s poll interval) -pub struct SystemMetricsState(pub Mutex); +pub struct SystemMetricsState(Mutex); impl Default for SystemMetricsState { fn default() -> Self { @@ -26,25 +26,7 @@ impl Default for SystemMetricsState { } } -fn app_processes(system: &System, root: Pid) -> HashSet { - // WebKit XPC services are re-parented to launchd on macOS, so a normal - // parent/child walk misses most of Bento's UI memory. Resource coalitions - // are the kernel grouping that keeps an app and those services together. - #[cfg(target_os = "macos")] - if let Some(root_coalition) = resource_coalition_id(root) { - let members: HashSet = system - .processes() - .keys() - .copied() - .filter(|pid| resource_coalition_id(*pid) == Some(root_coalition)) - .collect(); - if !members.is_empty() { - return members; - } - } - - // Linux and Windows keep the terminal/agent process tree attached to the - // host process. This is also the safe fallback if coalition lookup fails. +fn descendant_processes(system: &System, root: Pid) -> HashSet { let mut result = HashSet::from([root]); loop { let before = result.len(); @@ -62,6 +44,49 @@ fn app_processes(system: &System, root: Pid) -> HashSet { } } +fn app_processes(system: &System, root: Pid) -> HashSet { + // WebKit XPC services are re-parented to launchd on macOS, so a normal + // parent/child walk misses most of Bento's UI memory. Resource coalitions + // are the kernel grouping that keeps an app and those services together. + #[cfg(target_os = "macos")] + if let Some(root_coalition) = resource_coalition_id(root) { + let root_process = system.process(root); + let launched_as_app = root_process + .and_then(Process::parent) + .is_some_and(|parent| parent.as_u32() == 1); + + if launched_as_app { + // A packaged app launched by macOS owns an isolated coalition. This + // is the authoritative grouping and includes its re-parented XPCs. + let members: HashSet = system + .processes() + .keys() + .copied() + .filter(|pid| resource_coalition_id(*pid) == Some(root_coalition)) + .collect(); + if !members.is_empty() { + return members; + } + } else if let Some(started_at) = root_process.map(Process::start_time) { + // `tauri dev` inherits the terminal's long-lived coalition, which can + // contain hundreds of unrelated/old processes. Keep the real child + // tree and add only this run's re-parented WebKit XPC services. + let mut members = descendant_processes(system, root); + members.extend(system.processes().iter().filter_map(|(pid, process)| { + (process.start_time() >= started_at + && process.name().contains("WebKit") + && resource_coalition_id(*pid) == Some(root_coalition)) + .then_some(*pid) + })); + return members; + } + } + + // Linux and Windows keep the terminal/agent process tree attached to the + // host process. This is also the safe fallback if coalition lookup fails. + descendant_processes(system, root) +} + #[cfg(target_os = "macos")] fn resource_coalition_id(pid: Pid) -> Option { use std::ffi::c_void; @@ -227,7 +252,10 @@ pub fn app_memory_usage(state: State<'_, SystemMetricsState>) -> Result = ms.cached_pids.iter().copied().collect(); + for pid in cached_pids { ms.system.refresh_process(pid); } ms.calls_since_tree_refresh += 1; diff --git a/src/panels/tv/TVPanel.ts b/src/panels/tv/TVPanel.ts index 359a501..0315c31 100644 --- a/src/panels/tv/TVPanel.ts +++ b/src/panels/tv/TVPanel.ts @@ -10,13 +10,14 @@ import { renderGrid } from './grid' import { HLSPlayer } from './player' import { icon } from '../../ui/icons' import { getCurrentWindow } from '@tauri-apps/api/window' +import type { PanelInstance } from '../registry' // repo = lightweight base (M3U); worldRepo = heavy source loaded on demand export function createTVPanel( repo: ChannelRepository, favoritesRepo: FavoritesRepository, worldRepo?: ChannelRepository -): { element: HTMLElement; dispose: () => void } { +): PanelInstance { const root = document.createElement('div') root.className = 'tv-panel' From f6c4c904a26e95b7d76deefdb8b7c15bce4dea62 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 02:30:20 +0200 Subject: [PATCH 12/25] fix: preserve panel resources across visibility changes --- src/panels/docker/DockerPanel.ts | 19 +-- src/panels/docker/containerDetail.ts | 54 +++++++-- src/panels/tasks/TaskDataLoader.ts | 18 ++- src/panels/tasks/TaskDockerView.ts | 32 ++++-- src/panels/tasks/TasksPanelRuntime.ts | 117 ++++++++++++++----- src/panels/tv/player.ts | 18 +++ src/ui/agentStatusBar.ts | 36 ++++-- tests/panels/tasks/taskDataLoader.test.ts | 134 ++++++++++++++++++++++ tests/panels/tvPlayer.test.ts | 30 +++++ tests/ui/agentStatusBarLifecycle.test.ts | 46 ++++++++ 10 files changed, 440 insertions(+), 64 deletions(-) create mode 100644 tests/panels/tasks/taskDataLoader.test.ts create mode 100644 tests/panels/tvPlayer.test.ts create mode 100644 tests/ui/agentStatusBarLifecycle.test.ts diff --git a/src/panels/docker/DockerPanel.ts b/src/panels/docker/DockerPanel.ts index 43cab67..ee18f6e 100644 --- a/src/panels/docker/DockerPanel.ts +++ b/src/panels/docker/DockerPanel.ts @@ -1,14 +1,15 @@ import { t as i18nT } from '../../i18n' import { invoke } from '@tauri-apps/api/core' import { parseContainers, isRunning, groupByProject, runningCount, type Container } from '../../core/docker/containers' -import { renderContainerLogs, renderContainerTerminal } from './containerDetail' +import { renderContainerLogs, renderContainerTerminal, type DetailLifecycle } from './containerDetail' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' import { icon } from '../../ui/icons' export function createDockerPanel(filterPrefix?: string): { element: HTMLElement; dispose: () => void } { let containers: Container[] = [] // Teardown for the current detail's body (live stream / exec terminal). - let bodyCleanup: () => void = () => {} + const emptyLifecycle = (): DetailLifecycle => ({ pause: () => {}, resume: () => {}, dispose: () => {} }) + let bodyLifecycle = emptyLifecycle() const iconBtn = (name: string, title: string, onClick: () => void): HTMLButtonElement => { const b = document.createElement('button') @@ -124,18 +125,18 @@ export function createDockerPanel(filterPrefix?: string): { element: HTMLElement // ---- logs + terminal: delegated to shared containerDetail module ---- function showLogs(body: HTMLElement, c: Container): void { - bodyCleanup() - bodyCleanup = renderContainerLogs(c, body) + bodyLifecycle.dispose() + bodyLifecycle = renderContainerLogs(c, body) } async function showTerminal(body: HTMLElement, c: Container, backToLogs: () => void): Promise { - bodyCleanup() - bodyCleanup = await renderContainerTerminal(c, body, backToLogs) + bodyLifecycle.dispose() + bodyLifecycle = await renderContainerTerminal(c, body, backToLogs) } function renderDetail(name: string): void { - bodyCleanup() - bodyCleanup = () => {} + bodyLifecycle.dispose() + bodyLifecycle = emptyLifecycle() const c = find(name) if (!c) { detail.replaceChildren(Object.assign(document.createElement('div'), { className: 'docker-detail-hint', textContent: i18nT('docker.selectAContainerToViewItsDetailsAnd') })) @@ -192,5 +193,5 @@ export function createDockerPanel(filterPrefix?: string): { element: HTMLElement } load() - return { element: root, dispose: () => bodyCleanup() } + return { element: root, dispose: () => bodyLifecycle.dispose() } } diff --git a/src/panels/docker/containerDetail.ts b/src/panels/docker/containerDetail.ts index 64a32f2..52b4182 100644 --- a/src/panels/docker/containerDetail.ts +++ b/src/panels/docker/containerDetail.ts @@ -7,6 +7,14 @@ import { createTerminalPanel } from '../terminal/TerminalPanel' import { icon } from '../../ui/icons' import type { Container } from '../../core/docker/containers' +export interface DetailLifecycle { + pause: () => void + resume: () => void + dispose: () => void +} + +const noOp = (): void => {} + function btn(name: string, title: string, onClick: () => void): HTMLButtonElement { const b = document.createElement('button') b.className = 'docker-action' @@ -16,12 +24,16 @@ function btn(name: string, title: string, onClick: () => void): HTMLButtonElemen return b } -// Renders logs for a container into `target`. Returns a cleanup (stops live stream). -export function renderContainerLogs(c: Container, target: HTMLElement): () => void { +// Renders logs for a container into `target`. Live streaming can be paused while +// its panel is hidden without confusing that temporary pause with disposal. +export function renderContainerLogs(c: Container, target: HTMLElement): DetailLifecycle { const pre = document.createElement('pre') pre.className = 'docker-logs' let rawLogs = '', errorsOnly = false, live = false let unlisten: (() => void) | null = null + let resumeLive = false + let disposed = false + let streamGeneration = 0 const applyStatic = (): void => { pre.textContent = errorsOnly @@ -41,20 +53,29 @@ export function renderContainerLogs(c: Container, target: HTMLElement): () => vo pre.textContent += text pre.scrollTop = pre.scrollHeight } - const stopLive = (): void => { + const stopLiveStream = (): void => { if (!live) return live = false + streamGeneration += 1 liveBtn.innerHTML = icon('play'); liveBtn.title = i18nT('docker.followLiveLogs'); liveBtn.classList.remove('active') invoke('docker_logs_stop', { id: c.name }).catch(() => {}) unlisten?.(); unlisten = null } + const stopLive = (): void => { + resumeLive = false + stopLiveStream() + } const startLive = async (): Promise => { + if (disposed || live) return + const generation = ++streamGeneration live = true liveBtn.innerHTML = icon('stop'); liveBtn.title = i18nT('docker.stopFollowing'); liveBtn.classList.add('active') rawLogs = ''; pre.textContent = '' try { await invoke('docker_logs_follow', { id: c.name, tail: 200 }) - unlisten = await listen(`docker-logs-${c.name}`, e => onChunk(e.payload)) + const stopListening = await listen(`docker-logs-${c.name}`, e => onChunk(e.payload)) + if (disposed || !live || generation !== streamGeneration) stopListening() + else unlisten = stopListening } catch (e) { pre.textContent = String(e) } } @@ -82,15 +103,30 @@ export function renderContainerLogs(c: Container, target: HTMLElement): () => vo target.replaceChildren(head, pre) loadStatic() - return stopLive + return { + pause: () => { + resumeLive = live + stopLiveStream() + }, + resume: () => { + if (!resumeLive || disposed) return + resumeLive = false + void startLive() + }, + dispose: () => { + disposed = true + resumeLive = false + stopLiveStream() + }, + } } -// Renders a docker exec terminal into `target`. Returns a cleanup (disposes terminal). -export async function renderContainerTerminal(c: Container, target: HTMLElement, onBack?: () => void): Promise<() => void> { +// Interactive terminals deliberately stay alive while their panel is hidden. +export async function renderContainerTerminal(c: Container, target: HTMLElement, onBack?: () => void): Promise { const argv = await invoke('docker_exec_argv', { container: c.name }).catch(() => null) if (!argv) { target.replaceChildren(Object.assign(document.createElement('div'), { className: 'docker-detail-hint', textContent: i18nT('docker.couldNotOpenTheTerminal') })) - return () => {} + return { pause: noOp, resume: noOp, dispose: noOp } } const term = createTerminalPanel('', '', onBack, argv) const wrap = document.createElement('div') @@ -98,5 +134,5 @@ export async function renderContainerTerminal(c: Container, target: HTMLElement, wrap.appendChild(term.element) target.replaceChildren(wrap) requestAnimationFrame(() => term.fit()) - return () => term.dispose() + return { pause: noOp, resume: () => term.fit(), dispose: () => term.dispose() } } diff --git a/src/panels/tasks/TaskDataLoader.ts b/src/panels/tasks/TaskDataLoader.ts index 3bf4571..8b647c6 100644 --- a/src/panels/tasks/TaskDataLoader.ts +++ b/src/panels/tasks/TaskDataLoader.ts @@ -28,14 +28,20 @@ export async function loadTaskData(options: { upstream: Map } renderList: (statuses: Map, runningPaths: Set) => void + shouldRestoreSelection: () => boolean selectRow: (row: HTMLElement) => void showChanges: (wt: Worktree) => void showRebasePaused: (wt: Worktree, status: RebaseStatus) => void }): Promise { - const { repoPath, panelStore, baseSelect, filterInput, listWrap, fetchAgeEl, note, setBaseBranch, setWorktrees, setJiraConfig, maps, renderList, selectRow, showChanges, showRebasePaused } = options + const { repoPath, panelStore, baseSelect, filterInput, listWrap, fetchAgeEl, note, setBaseBranch, setWorktrees, setJiraConfig, maps, renderList, shouldRestoreSelection, selectRow, showChanges, showRebasePaused } = options baseSelect.disabled = false filterInput.style.display = '' listWrap.replaceChildren(note(taskT('loading'), 'db-detail-loading')) + // Capture restoration state before any asynchronous enrichment. A user may + // select a freshly rendered row while Docker/Jira/GitHub data is still + // loading; that new interaction must not be mistaken for startup recovery + // and rebuild the detail view underneath their input. + const savedPath = panelStore.selected() try { const [defaultBranch, remoteBranches] = await Promise.all([ invoke('git_default_branch', { repo: repoPath }).catch(() => 'main'), @@ -48,6 +54,11 @@ export async function loadTaskData(options: { baseSelect.replaceChildren(...remoteBranches.map(branch => Object.assign(document.createElement('option'), { value: branch, textContent: taskT('baseOption', { branch }), selected: branch === baseBranch }))) const worktrees = await taskGit.worktrees(repoPath) setWorktrees(worktrees) + // Worktree discovery is the only data required to build the task list. + // Render it immediately: optional integrations such as Docker, Jira and + // GitHub CLI can be slow (or wait for a daemon/login) on CI and Windows, + // but must never leave the whole panel stuck on “Loading…”. + renderList(new Map(worktrees.map(wt => [wt.path, 0])), new Set()) const fetchedAt = worktrees[0] ? (await invoke<{ fetchedAt: number }>('git_fetch_info', { path: worktrees[0].path }).catch(() => ({ fetchedAt: 0 }))).fetchedAt : 0 if (fetchedAt) { const ageMinutes = Math.max(0, Math.floor((Date.now() / 1000 - fetchedAt) / 60)) @@ -75,8 +86,9 @@ export async function loadTaskData(options: { if (allContainers.some(c => isRunning(c) && c.name.startsWith(`${dir}-`))) runningPaths.add(wt.path) }) renderList(statuses, runningPaths) - const savedPath = panelStore.selected() - const selectedWt = worktrees.find(w => maps.rebase.get(w.path)?.active) ?? (savedPath ? worktrees.find(w => w.path === savedPath) : undefined) + const selectedWt = shouldRestoreSelection() + ? worktrees.find(w => maps.rebase.get(w.path)?.active) ?? (savedPath ? worktrees.find(w => w.path === savedPath) : undefined) + : undefined if (selectedWt) { const rows = listWrap.querySelectorAll('.tasks-row') const row = [...rows].find(item => item.dataset.path === selectedWt.path) diff --git a/src/panels/tasks/TaskDockerView.ts b/src/panels/tasks/TaskDockerView.ts index b0161fd..45aeabf 100644 --- a/src/panels/tasks/TaskDockerView.ts +++ b/src/panels/tasks/TaskDockerView.ts @@ -5,7 +5,7 @@ import { confirm as askConfirm } from '@tauri-apps/plugin-dialog' import { isRunning, parseContainers, type Container } from '../../core/docker/containers' import type { Worktree } from '../../core/git/worktree' import { icon } from '../../ui/icons' -import { renderContainerLogs, renderContainerTerminal } from '../docker/containerDetail' +import { renderContainerLogs, renderContainerTerminal, type DetailLifecycle } from '../docker/containerDetail' import { taskT } from './i18n' export interface IsolateResult { @@ -42,7 +42,7 @@ export interface RecipeApplyResult { interface TaskDockerViewOptions { showDetail: (...nodes: HTMLElement[]) => void resetDetail: () => void - setCleanup: (cleanup: () => void, resume?: () => void) => void + setLifecycle: (lifecycle: DetailLifecycle) => void } function iconButton(name: string, title: string, onClick: () => void): HTMLButtonElement { @@ -79,7 +79,7 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { body.className = 'tasks-logs-body' wrap.append(subHeader(shortName, goBack), body) options.showDetail(wrap) - options.setCleanup(renderContainerLogs(container, body)) + options.setLifecycle(renderContainerLogs(container, body)) } const showContainerTerminal = async (container: Container, shortName: string, goBack: () => void): Promise => { @@ -90,7 +90,7 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { body.className = 'tasks-term-body' wrap.append(subHeader(shortName, goBack), body) options.showDetail(wrap) - options.setCleanup(await renderContainerTerminal(container, body, goBack)) + options.setLifecycle(await renderContainerTerminal(container, body, goBack)) } const showStackLogs = (worktree: Worktree, worktreeDirectory: string, goBack: () => void): void => { @@ -100,14 +100,18 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { const logsBody = document.createElement('div') logsBody.className = 'tasks-logs-body' let live = false + let resumeLive = false + let disposed = false + let streamGeneration = 0 let unlisten: (() => void) | null = null const event = `docker-compose-logs-${worktreeDirectory}` const output = document.createElement('pre') output.className = 'docker-logs' - const stopLive = (): void => { + const stopLiveStream = (): void => { if (!live) return live = false + streamGeneration += 1 liveButton.innerHTML = icon('play') liveButton.title = taskT('followLiveLogs') liveButton.classList.remove('active') @@ -115,7 +119,13 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { unlisten?.() unlisten = null } + const stopLive = (): void => { + resumeLive = false + stopLiveStream() + } const startLive = async (): Promise => { + if (disposed || live) return + const generation = ++streamGeneration live = true liveButton.innerHTML = icon('stop') liveButton.title = taskT('stopFollowingLogs') @@ -123,10 +133,12 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { output.textContent = '' try { await invoke('docker_compose_logs_follow', { worktreePath: worktree.path, tail: 200 }) - unlisten = await listen(event, eventData => { + const stopListening = await listen(event, eventData => { output.textContent += eventData.payload output.scrollTop = output.scrollHeight }) + if (disposed || !live || generation !== streamGeneration) stopListening() + else unlisten = stopListening } catch (error) { output.textContent = String(error) } @@ -150,7 +162,11 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { logsBody.append(header, output) wrap.append(subHeader(taskT('stackLogs'), goBack), logsBody) options.showDetail(wrap) - options.setCleanup(stopLive) + options.setLifecycle({ + pause: () => { resumeLive = live; stopLiveStream() }, + resume: () => { if (resumeLive && !disposed) { resumeLive = false; void startLive() } }, + dispose: () => { disposed = true; resumeLive = false; stopLiveStream() }, + }) void startLive() } @@ -228,7 +244,7 @@ export function createTaskDockerView(options: TaskDockerViewOptions) { let pollInterval: ReturnType | null = setInterval(refresh, 3000) const stopPoll = (): void => { if (pollInterval !== null) { clearInterval(pollInterval); pollInterval = null } } const resumePoll = (): void => { stopPoll(); void refresh(); pollInterval = setInterval(refresh, 3000) } - options.setCleanup(stopPoll, resumePoll) + options.setLifecycle({ pause: stopPoll, resume: resumePoll, dispose: stopPoll }) options.showDetail(wrap) } diff --git a/src/panels/tasks/TasksPanelRuntime.ts b/src/panels/tasks/TasksPanelRuntime.ts index cefb239..3528e2c 100644 --- a/src/panels/tasks/TasksPanelRuntime.ts +++ b/src/panels/tasks/TasksPanelRuntime.ts @@ -36,6 +36,7 @@ import { TauriAppSettingsRepository } from '../../adapters/TauriAppSettingsRepos import type { AppSettings } from '../../ports/AppSettingsRepository' import { isRunning, parseContainers } from '../../core/docker/containers' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' +import type { DetailLifecycle } from '../docker/containerDetail' export function createTasksPanel(panelId = 'default'): { element: HTMLElement; dispose: () => void; onVisibilityChange: (visible: boolean) => void } { const panelStore = new TaskPanelStore(panelId) @@ -45,6 +46,27 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d let worktrees: Worktree[] = [] let repoPath = panelStore.repository() let detailCleanup: () => void = () => {} + let detailPause: () => void = () => {} + let detailResume: () => void = () => {} + let panelVisible = true + const disposeDetail = (): void => { + // Invalidate async work started by the outgoing detail. Stopping an + // interval is not enough when one of its refresh requests is already in + // flight: without a new generation it could finish later and replace the + // newly opened commit/rebase/conflict UI. + detailVersion += 1 + const cleanup = detailCleanup + detailCleanup = () => {} + detailPause = () => {} + detailResume = () => {} + cleanup() + } + const setDetailLifecycle = (lifecycle: DetailLifecycle): void => { + detailCleanup = lifecycle.dispose + detailPause = lifecycle.pause + detailResume = lifecycle.resume + if (!panelVisible) detailPause() + } // Live agents hub per worktree. Kept alive across detail navigation so the // running agents/terminals survive switching to changes/history and back; // disposed only when the worktree is removed or the whole panel closes. @@ -282,11 +304,10 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d ) return head } - let detailResume: () => void = () => {} const dockerView = createTaskDockerView({ showDetail, - resetDetail: () => { stopDiffRefresh(); detailCleanup(); detailCleanup = () => {}; detailResume = () => {} }, - setCleanup: (cleanup, resume = () => {}) => { detailCleanup = cleanup; detailResume = resume }, + resetDetail: () => { stopDiffRefresh(); disposeDetail() }, + setLifecycle: setDetailLifecycle, }) const defaultProjectKey = (repository = repoPath): string => repository.replace(/\/$/, '').split('/').pop() ?? '' @@ -306,7 +327,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d async function showTaskSettings(): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loading'), 'db-detail-loading')) await settingsReady @@ -954,7 +975,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: changes (GitHub-style diff + commit bar) ---- async function showChanges(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() const requestVersion = ++detailVersion showDetail(note(taskT('loadingChanges'), 'db-detail-loading')) try { @@ -968,7 +989,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d showDetail(buildDiffView(raw, wt, { statusRaw: statusRaw.raw, rebaseActive })) // Auto-refresh: re-fetch diff every 5 s and update if content changed let lastSnapshot = `${statusRaw.raw}\0${raw}` - diffRefreshInterval = setInterval(async () => { + const refreshChanges = async (): Promise => { const [newRaw, newStatus] = await Promise.all([ invoke('git_diff', { path: wt.path }).catch(() => null), taskGit.safeStatus(wt.path), @@ -976,11 +997,28 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d if (requestVersion !== detailVersion) return const snapshot = `${newStatus.raw}\0${newRaw ?? ''}` if (newRaw !== null && snapshot !== lastSnapshot) { + const draft = detailPane.querySelector('[data-testid="tasks-commit-message"]') + // Replacing the entire diff also replaces the commit controls. Keep + // the current DOM stable while the user (or WebDriver) is editing so + // their text and the button they are about to activate cannot become + // stale underneath them. Once editing ends, the pending snapshot is + // intentionally retried on the next interval. + if (draft && (draft.value.length > 0 || document.activeElement === draft)) return lastSnapshot = snapshot showDetail(buildDiffView(newRaw, wt, { statusRaw: newStatus.raw, rebaseActive })) } - }, 5000) - detailCleanup = () => stopDiffRefresh() + } + const startDiffRefresh = (): void => { + stopDiffRefresh() + if (requestVersion !== detailVersion) return + diffRefreshInterval = setInterval(() => { void refreshChanges() }, 5000) + } + startDiffRefresh() + setDetailLifecycle({ + pause: stopDiffRefresh, + resume: () => { void refreshChanges(); startDiffRefresh() }, + dispose: stopDiffRefresh, + }) } catch (e) { showDetail(note(String(e), 'db-detail-error')) } } @@ -1111,7 +1149,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: choose an existing commit for fixup ---- async function showFixupPicker(wt: Worktree, files: string[] | undefined, incomingDiff: string, selectedPatch?: string): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loadingCommits'), 'db-detail-loading')) try { const worktreeBase = baseFor(wt) @@ -1244,7 +1282,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: automatic history backups ---- async function showBackupHistory(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loadingBackups'), 'db-detail-loading')) try { showDetail(await buildBackupHistoryView({ @@ -1258,7 +1296,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d function showOperationHistory(wt: Worktree): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() const branch = wt.branch ?? taskT('detached') const repository = repositoryFor(wt) showDetail(buildOperationHistoryView({ @@ -1276,7 +1314,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: reset commits ---- function showResetView(wt: Worktree): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(buildResetView({ worktree: wt, baseBranch: baseFor(wt), @@ -1290,7 +1328,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: commit log ---- async function showCommitGraph(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() await buildGraphView({ worktree: wt, baseBranch: baseFor(wt), @@ -1303,7 +1341,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d function showPrDetails(wt: Worktree, pr: PrStatus): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(buildPrStatusView({ pr, baseBranch: baseFor(wt), onBack: () => showChanges(wt), @@ -1313,7 +1351,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d async function showCommitLog(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loadingHistory'), 'db-detail-loading')) try { const entries = await taskGit.log(wt.path) @@ -1334,7 +1372,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: interactive rebase ---- async function showInteractiveRebase(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() showDetail(note(taskT('loading'), 'db-detail-loading')) try { const st = await invoke('git_rebase_status', { path: wt.path }) @@ -1636,10 +1674,12 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- Inline conflict resolver ---- function showConflictResolver(wt: Worktree, file: string, onBack: () => void): void { stopDiffRefresh() + disposeDetail() showDetail(buildConflictResolverView({ path: wt.path, file, onBack })) } function showRebasePaused(wt: Worktree, st: RebaseStatus): void { + disposeDetail() const wrap = document.createElement('div') wrap.className = 'tasks-rebase-paused' @@ -1677,6 +1717,11 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d const continueBtn = Object.assign(document.createElement('button'), { className: 'tasks-commit-btn', textContent: taskT('continueRebase') }) let intervalId = 0 + const stopPolling = (): void => { + clearInterval(intervalId) + intervalId = 0 + } + let resumePolling: () => void editBtn.addEventListener('click', () => showChanges(wt)) splitBtn.addEventListener('click', async () => { @@ -1803,19 +1848,25 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d wrap.appendChild(conflictList) // Auto-refresh conflict list in case user resolves from terminal - intervalId = window.setInterval(async () => { + const refreshConflicts = async (): Promise => { const fresh = await invoke('git_rebase_status', { path: wt.path }).catch(() => null) if (!fresh) return - if (!fresh.active) { clearInterval(intervalId); showChanges(wt); load(); return } + if (!fresh.active) { stopPolling(); showChanges(wt); load(); return } const freshConflicts = fresh.conflicts ?? [] freshConflicts.forEach(f => { if (!freshConflicts.includes(f)) resolved.delete(f) }) if (freshConflicts.length === 0) { - clearInterval(intervalId) + stopPolling() showRebasePaused(wt, fresh) } else { renderConflicts(freshConflicts) } - }, 4000) + } + const startPolling = (): void => { + stopPolling() + intervalId = window.setInterval(() => { void refreshConflicts() }, 4000) + } + resumePolling = () => { void refreshConflicts(); startPolling() } + startPolling() continueBtn.disabled = conflicts.length > 0 @@ -1835,11 +1886,16 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d }).catch(() => {}) } refreshDiff() - intervalId = window.setInterval(refreshDiff, 5000) + const startPolling = (): void => { + stopPolling() + intervalId = window.setInterval(refreshDiff, 5000) + } + resumePolling = () => { refreshDiff(); startPolling() } + startPolling() wrap.appendChild(diffWrap) } - detailCleanup = () => clearInterval(intervalId) + setDetailLifecycle({ pause: stopPolling, resume: resumePolling, dispose: stopPolling }) actionsEl.append(statusEl, abortBtn, editBtn, splitBtn, continueBtn) wrap.appendChild(actionsEl) showDetail(wrap) @@ -1848,7 +1904,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // ---- detail: worktree terminal ---- async function showWorktreeTerminal(wt: Worktree): Promise { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() const { createAgentsPanel } = await import('../agents/AgentsPanel') // Reuse the live hub for this worktree if we already opened it; otherwise // create one scoped to the worktree (own storage, off the global dock). @@ -1869,13 +1925,17 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // hub stays alive in the cache, so the agents keep running. Persist on leave // so they're restorable even if the tab closes without a clean dispose. const livePanel = panel - detailCleanup = () => livePanel.persist() + setDetailLifecycle({ + pause: () => {}, + resume: () => livePanel.fit(), + dispose: () => livePanel.persist(), + }) } // ---- detail: git sync error (with conflict detection + AI explain) ---- function showSyncError(mode: string, errorText: string, wt: Worktree): void { stopDiffRefresh() - detailCleanup(); detailCleanup = () => {} + disposeDetail() buildSyncErrorView({ mode, errorText, path: wt.path, showDetail, iconButton: iconBtn, status: path => taskGit.status(path) }) } @@ -2013,6 +2073,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d } baseSelect.disabled = false baseRow.style.display = '' + const selectionVersionAtLoad = selectionVersion await loadTaskData({ repoPath, panelStore, @@ -2026,6 +2087,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d setJiraConfig: value => { jiraCfg = value }, maps: { issue: issueMap, aheadBehind: aheadBehindMap, pr: prStatusMap, backup: backupStatusMap, rebase: rebaseStatusMap, upstream: upstreamStatusMap }, renderList, + shouldRestoreSelection: () => selectionVersion === selectionVersionAtLoad, selectRow, showChanges, showRebasePaused, @@ -2052,7 +2114,7 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d // Dispose all live worktree hubs when the panel/tab closes (persists each). const dispose = (): void => { stopDiffRefresh() - detailCleanup() + disposeDetail() for (const panel of worktreeTerminals.values()) panel.dispose() worktreeTerminals.clear() } @@ -2060,7 +2122,8 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d element: root, dispose, onVisibilityChange: (visible: boolean) => { - if (!visible) { stopDiffRefresh(); detailCleanup() } + panelVisible = visible + if (!visible) detailPause() else detailResume() }, } diff --git a/src/panels/tv/player.ts b/src/panels/tv/player.ts index ce6a018..d978fe1 100644 --- a/src/panels/tv/player.ts +++ b/src/panels/tv/player.ts @@ -11,6 +11,8 @@ export class HLSPlayer { readonly element: HTMLDivElement private readonly video: HTMLVideoElement private readonly iframe: HTMLIFrameElement + private iframeUrl: string | null = null + private iframePaused = false onStatus?: (status: PlayerStatus) => void constructor() { @@ -44,6 +46,8 @@ export class HLSPlayer { if (isEmbedUrl(url)) { this.video.classList.add('hidden') this.iframe.classList.remove('hidden') + this.iframeUrl = url + this.iframePaused = false this.iframe.src = url this.onStatus?.('playing') return @@ -89,9 +93,21 @@ export class HLSPlayer { pause(): void { this.video.pause() this.hls?.stopLoad() + if (!this.iframe.classList.contains('hidden') && this.iframe.hasAttribute('src')) { + // Third-party embeds cannot be paused reliably with postMessage. Unload + // them while hidden to stop audio, network traffic and retained memory. + this.iframePaused = true + this.iframe.removeAttribute('src') + } } resume(): void { + if (this.iframePaused && this.iframeUrl) { + this.iframePaused = false + this.iframe.src = this.iframeUrl + this.onStatus?.('playing') + return + } if (this.video.classList.contains('hidden') || !this.video.src) return this.hls?.startLoad() this.video.play().catch(() => {}) @@ -113,6 +129,8 @@ export class HLSPlayer { } this.video.removeAttribute('src') this.video.load() + this.iframeUrl = null + this.iframePaused = false this.iframe.removeAttribute('src') } diff --git a/src/ui/agentStatusBar.ts b/src/ui/agentStatusBar.ts index f6cb694..2d2906c 100644 --- a/src/ui/agentStatusBar.ts +++ b/src/ui/agentStatusBar.ts @@ -100,18 +100,40 @@ export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { // Schedule the next RAM read during idle time (avoids contending with frame // rendering), but with a hard deadline so it still fires under sustained load. - let idleHandle: ReturnType | number | undefined + let timeoutHandle: number | undefined + let idleHandle: number | undefined + let disposed = false + const cancelScheduledRefresh = (): void => { + if (timeoutHandle !== undefined) { + window.clearTimeout(timeoutHandle) + timeoutHandle = undefined + } + if (idleHandle !== undefined) { + if (typeof cancelIdleCallback !== 'undefined') cancelIdleCallback(idleHandle) + idleHandle = undefined + } + } const scheduleRefresh = (): void => { - idleHandle = window.setTimeout(() => { + if (disposed || document.hidden) return + cancelScheduledRefresh() + timeoutHandle = window.setTimeout(() => { + timeoutHandle = undefined + if (disposed || document.hidden) return if (typeof requestIdleCallback !== 'undefined') { - idleHandle = requestIdleCallback(() => void refreshMemory().finally(scheduleRefresh), { timeout: 2000 }) + idleHandle = requestIdleCallback(() => { + idleHandle = undefined + void refreshMemory().finally(scheduleRefresh) + }, { timeout: 2000 }) } else { void refreshMemory().finally(scheduleRefresh) } }, 3000) } - const onVisibilityChange = (): void => { if (!document.hidden) void refreshMemory().finally(scheduleRefresh) } + const onVisibilityChange = (): void => { + cancelScheduledRefresh() + if (!document.hidden && !disposed) void refreshMemory().finally(scheduleRefresh) + } document.addEventListener('visibilitychange', onVisibilityChange) element.replaceChildren(agents, memory) render() @@ -120,12 +142,10 @@ export function createAgentStatusBar({ onOpenAgents }: AgentStatusBarOptions): { return { element, dispose: () => { + disposed = true window.removeEventListener(AGENT_DOCK_EVENT, onDock) document.removeEventListener('visibilitychange', onVisibilityChange) - if (typeof idleHandle === 'number') { - clearTimeout(idleHandle) - if (typeof cancelIdleCallback !== 'undefined') cancelIdleCallback(idleHandle) - } + cancelScheduledRefresh() }, } } diff --git a/tests/panels/tasks/taskDataLoader.test.ts b/tests/panels/tasks/taskDataLoader.test.ts new file mode 100644 index 0000000..b2e60ba --- /dev/null +++ b/tests/panels/tasks/taskDataLoader.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { invokeMock, taskGitMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), + taskGitMock: { + remoteBranches: vi.fn(), + worktrees: vi.fn(), + safeStatus: vi.fn(), + }, +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) +vi.mock('../../../src/panels/tasks/taskGitClient', () => ({ taskGit: taskGitMock })) +vi.mock('../../../src/panels/tasks/taskJiraClient', () => ({ + loadJiraConfig: vi.fn(async () => null), + fetchIssue: vi.fn(async () => null), +})) + +import { loadTaskData } from '../../../src/panels/tasks/TaskDataLoader' + +describe('loadTaskData', () => { + beforeEach(() => { + vi.clearAllMocks() + taskGitMock.remoteBranches.mockResolvedValue([]) + taskGitMock.worktrees.mockResolvedValue([ + { path: 'C:/repo', branch: 'main', head: 'abc', bare: false }, + { path: 'C:/tarea unicode ñ', branch: 'task/e2e', head: 'def', bare: false }, + ]) + taskGitMock.safeStatus.mockResolvedValue({ raw: '', staged: 0, unstaged: 0, untracked: 0, total: 0 }) + }) + + it('renders discovered worktrees before optional integrations finish', async () => { + const dockerNeverCompletes = new Promise(() => {}) + invokeMock.mockImplementation((command: string) => { + if (command === 'git_default_branch') return Promise.resolve('main') + if (command === 'git_fetch_info') return Promise.resolve({ fetchedAt: 0 }) + if (command === 'docker_list') return dockerNeverCompletes + return Promise.resolve(null) + }) + const renderList = vi.fn() + + void loadTaskData({ + repoPath: 'C:\\repo', + panelStore: { + savedBase: () => null, + setBase: () => {}, + selected: () => null, + setSelected: () => {}, + }, + baseSelect: document.createElement('select'), + filterInput: document.createElement('input'), + listWrap: document.createElement('div'), + fetchAgeEl: document.createElement('span'), + note: text => Object.assign(document.createElement('div'), { textContent: text }), + setBaseBranch: () => {}, + setWorktrees: () => {}, + setJiraConfig: () => {}, + maps: { + issue: new Map(), + aheadBehind: new Map(), + pr: new Map(), + backup: new Map(), + rebase: new Map(), + upstream: new Map(), + }, + renderList, + shouldRestoreSelection: () => true, + selectRow: () => {}, + showChanges: () => {}, + showRebasePaused: () => {}, + }) + + for (let index = 0; index < 8; index += 1) await Promise.resolve() + + expect(renderList).toHaveBeenCalledTimes(1) + expect([...renderList.mock.calls[0][0].keys()]).toEqual(['C:/repo', 'C:/tarea unicode ñ']) + }) + + it('does not restore a selection made during progressive enrichment', async () => { + invokeMock.mockImplementation((command: string) => { + if (command === 'git_default_branch') return Promise.resolve('main') + if (command === 'git_fetch_info') return Promise.resolve({ fetchedAt: 0 }) + if (command === 'docker_list' || command === 'git_ahead_behind') return Promise.resolve('') + if (command === 'git_backup_status') return Promise.resolve({ available: false, different: null, hash: null, short: null, subject: null }) + if (command === 'git_rebase_status') return Promise.resolve({ active: false }) + return Promise.resolve(null) + }) + let selectedPath: string | null = 'C:/tarea unicode ñ' + let interacted = false + const listWrap = document.createElement('div') + const showChanges = vi.fn() + const renderList = vi.fn(() => { + interacted = true + const rows = ['C:/repo', 'C:/tarea unicode ñ'].map(path => { + const row = document.createElement('div') + row.className = 'tasks-row' + row.dataset.path = path + return row + }) + listWrap.replaceChildren(...rows) + }) + + await loadTaskData({ + repoPath: 'C:\\repo', + panelStore: { + savedBase: () => null, + setBase: () => {}, + selected: () => selectedPath, + setSelected: path => { selectedPath = path }, + }, + baseSelect: document.createElement('select'), + filterInput: document.createElement('input'), + listWrap, + fetchAgeEl: document.createElement('span'), + note: text => Object.assign(document.createElement('div'), { textContent: text }), + setBaseBranch: () => {}, + setWorktrees: () => {}, + setJiraConfig: () => {}, + maps: { + issue: new Map(), aheadBehind: new Map(), pr: new Map(), + backup: new Map(), rebase: new Map(), upstream: new Map(), + }, + renderList, + shouldRestoreSelection: () => !interacted, + selectRow: () => {}, + showChanges, + showRebasePaused: () => {}, + }) + + expect(renderList).toHaveBeenCalledTimes(2) + expect(showChanges).not.toHaveBeenCalled() + }) +}) diff --git a/tests/panels/tvPlayer.test.ts b/tests/panels/tvPlayer.test.ts new file mode 100644 index 0000000..9928f86 --- /dev/null +++ b/tests/panels/tvPlayer.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { HLSPlayer } from '../../src/panels/tv/player' + +describe('HLSPlayer embedded playback lifecycle', () => { + it('unloads a third-party iframe while hidden and restores it when visible', async () => { + const player = new HLSPlayer() + const streamUrl = 'https://www.youtube.com/embed/example' + await player.play({ + id: 'example', + name: 'Example', + logo: '', + country: '', + categories: [], + streamUrl, + }) + + const iframe = player.element.querySelector('iframe')! + expect(iframe.getAttribute('src')).toBe(streamUrl) + + player.pause() + expect(iframe.hasAttribute('src')).toBe(false) + + player.resume() + expect(iframe.getAttribute('src')).toBe(streamUrl) + + player.dispose() + expect(iframe.hasAttribute('src')).toBe(false) + }) +}) diff --git a/tests/ui/agentStatusBarLifecycle.test.ts b/tests/ui/agentStatusBarLifecycle.test.ts new file mode 100644 index 0000000..01323f9 --- /dev/null +++ b/tests/ui/agentStatusBarLifecycle.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { invokeMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(async () => 256 * 1024 * 1024), +})) + +vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })) + +import { createAgentStatusBar } from '../../src/ui/agentStatusBar' + +const settle = async (): Promise => { + for (let index = 0; index < 6; index += 1) await Promise.resolve() +} + +describe('agent status bar memory polling lifecycle', () => { + afterEach(() => { + vi.useRealTimers() + invokeMock.mockClear() + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + }) + + it('keeps one scheduled refresh across visibility changes and cancels it on dispose', async () => { + vi.useFakeTimers() + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + const bar = createAgentStatusBar({ onOpenAgents: () => {} }) + await settle() + expect(vi.getTimerCount()).toBe(1) + + Object.defineProperty(document, 'hidden', { configurable: true, value: true }) + document.dispatchEvent(new Event('visibilitychange')) + expect(vi.getTimerCount()).toBe(0) + + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + document.dispatchEvent(new Event('visibilitychange')) + await settle() + expect(vi.getTimerCount()).toBe(1) + + document.dispatchEvent(new Event('visibilitychange')) + await settle() + expect(vi.getTimerCount()).toBe(1) + + bar.dispose() + expect(vi.getTimerCount()).toBe(0) + }) +}) From c1f97f182f9c78374f229e2dfbecece37bf3c4ff Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:20:39 +0200 Subject: [PATCH 13/25] =?UTF-8?q?feat:=20removed=20panel=20context=20menu?= =?UTF-8?q?=20and=20group=20+=20button=20=E2=80=94=20multiple=20instances?= =?UTF-8?q?=20handled=20inside=20terminal=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/createWorkspaceView.ts | 60 ---------------------------------- src/i18n/en.json | 9 ----- src/i18n/es.json | 9 ----- src/styles.css | 26 --------------- 4 files changed, 104 deletions(-) diff --git a/src/app/createWorkspaceView.ts b/src/app/createWorkspaceView.ts index 16b78f7..68c9e4e 100644 --- a/src/app/createWorkspaceView.ts +++ b/src/app/createWorkspaceView.ts @@ -2,15 +2,11 @@ import { createDockview, type DockviewApi, type AddPanelOptions } from 'dockview import type { PanelRegistry } from '../panels/registry' import { lowestAvailableNumber } from '../core/terminal/lowestAvailableNumber' import { cycleTheme } from '../panels/terminal/themePreference' -import { showContextMenu } from '../ui/contextMenu' -import { furthestEdgeIndex, type MoveDirection } from '../core/workspace/edge' import { icon } from '../ui/icons' import { isMac, shortcutLabel } from '../ui/platform' import { currentPanelIndex } from '../core/workspace/currentPanel' import { appT } from '../core/i18n' -export type SplitDirection = 'within' | 'left' | 'right' | 'above' | 'below' - export interface WorkspaceView { element: HTMLElement fit: () => void @@ -66,24 +62,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt api.addPanel({ id: `${type}-${n}`, component: type, title: `${def.title} ${n}`, position }) } - const splitFrom = (refId: string, direction: SplitDirection): void => - addPanel(typeOf(refId), { referencePanel: refId, direction }) - - // Move a panel to the edge of the layout (alternative to dragging, which the - // macOS WebView doesn't support for HTML5 drag-and-drop). moveTo needs a - // target group: we pick the group at the requested edge (pure logic in core/edge). - const edgeOf = { right: 'right', left: 'left', above: 'top', below: 'bottom' } as const - const movePanel = (id: string, direction: MoveDirection): void => { - const panel = api.getPanel(id) - if (!panel) return - const groups = api.groups - const i = furthestEdgeIndex(groups.map(g => g.element.getBoundingClientRect()), direction) - const target = groups[i] - const movingIntoOwnLoneGroup = target === panel.group && target.panels.length === 1 - if (movingIntoOwnLoneGroup) return - panel.api.moveTo({ group: target, position: edgeOf[direction] }) - } - const addInActiveGroup = (type: string): void => addPanel(type, api.activeGroup ? { referenceGroup: api.activeGroup, direction: 'within' } : undefined) @@ -105,9 +83,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt instanceMap.set(id, instance) fits.add(instance.fit ?? (() => {})) - // The tab bar (with its × close) is hidden, so wrap each panel and overlay a - // hover close button in the corner — reliable across win/mac/linux (macOS's - // WKWebView swallows the right-click menu). Kept alongside the context menu. const wrapper = document.createElement('div') wrapper.className = 'panel-wrapper' wrapper.appendChild(instance.element) @@ -121,23 +96,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt closeBtn.addEventListener('click', () => removePanel(id)) wrapper.appendChild(closeBtn) - // Context menu: close, split, move (HTML5 drag doesn't work in WKWebView) - wrapper.addEventListener('contextmenu', e => { - e.preventDefault() - showContextMenu(e.clientX, e.clientY, [ - { label: appT('closePanel'), onClick: () => removePanel(id) }, - { label: appT('moveRight'), onClick: () => movePanel(id, 'right') }, - { label: appT('moveLeft'), onClick: () => movePanel(id, 'left') }, - { label: appT('moveUp'), onClick: () => movePanel(id, 'above') }, - { label: appT('moveDown'), onClick: () => movePanel(id, 'below') }, - { label: appT('splitRight'), onClick: () => splitFrom(id, 'right') }, - { label: appT('splitLeft'), onClick: () => splitFrom(id, 'left') }, - { label: appT('splitUp'), onClick: () => splitFrom(id, 'above') }, - { label: appT('splitDown'), onClick: () => splitFrom(id, 'below') }, - { label: appT('newTab', { name: def.title }), onClick: () => splitFrom(id, 'within') }, - ]) - }) - return { element: wrapper, init: params => { @@ -163,21 +121,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt }, } }, - createRightHeaderActionComponent: () => { - const btn = document.createElement('button') - btn.className = 'group-add-tab' - btn.textContent = '+' - btn.title = appT('addPanel') - const onClick = () => { - const rect = btn.getBoundingClientRect() - showContextMenu(rect.right, rect.bottom, panels.list().map(d => ({ - label: d.title, - onClick: () => addInActiveGroup(d.type), - })), { align: 'right' }) - } - btn.addEventListener('click', onClick) - return { element: btn, init: () => {}, dispose: () => btn.removeEventListener('click', onClick) } - }, }) function removePanel(id: string): void { @@ -269,9 +212,6 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt if (e.key === 't') { e.preventDefault() addInActiveGroup('terminal') - } else if (e.key === 'd' && active) { - e.preventDefault() - splitFrom(active.id, e.shiftKey ? 'below' : 'right') } else if (e.key === 'j') { // The focused terminal cycles its local theme; outside it, the global one. const inTerminal = active ? typeOf(active.id) === 'terminal' : false diff --git a/src/i18n/en.json b/src/i18n/en.json index 3d495cb..a755621 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -37,15 +37,6 @@ "windowBorders": "Window borders", "theme": "Theme: {name}", "panelNotRegistered": "Panel not registered: {name}", - "moveRight": "↦ Move right", - "moveLeft": "↤ Move left", - "moveUp": "↥ Move up", - "moveDown": "↧ Move down", - "splitRight": "Split right", - "splitLeft": "Split left", - "splitUp": "Split above", - "splitDown": "Split below", - "newTab": "New tab ({name})", "panelDb": "Databases", "panelDocker": "Docker", "panelHttp": "HTTP", diff --git a/src/i18n/es.json b/src/i18n/es.json index 611a019..98a09d3 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -37,15 +37,6 @@ "windowBorders": "Bordes de ventana", "theme": "Tema: {name}", "panelNotRegistered": "Panel no registrado: {name}", - "moveRight": "↦ Mover a la derecha", - "moveLeft": "↤ Mover a la izquierda", - "moveUp": "↥ Mover arriba", - "moveDown": "↧ Mover abajo", - "splitRight": "Dividir derecha", - "splitLeft": "Dividir izquierda", - "splitUp": "Dividir arriba", - "splitDown": "Dividir abajo", - "newTab": "Nueva pestaña ({name})", "panelDb": "Bases de datos", "panelDocker": "Docker", "panelHttp": "HTTP", diff --git a/src/styles.css b/src/styles.css index e5ceda6..1114049 100644 --- a/src/styles.css +++ b/src/styles.css @@ -707,23 +707,6 @@ html, body, #app { .term-profile-save:hover { color: var(--accent); border-color: var(--accent); } -/* "+" button in the header of each group */ -.group-add-tab { - height: 100%; - min-width: 28px; - padding: 0 8px; - background: transparent; - border: none; - color: var(--fg-dim); - font-size: 16px; - line-height: 1; - cursor: pointer; -} - -.group-add-tab:hover { - background: var(--surface-2); - color: var(--fg); -} /* Context menu */ .context-menu { @@ -2217,15 +2200,6 @@ html, body, #app { opacity: 1; } -/* Group header "+" (Dockview): always visible */ -.group-add-tab { - opacity: 1; - color: var(--fg-dim); -} - -.group-add-tab:hover { - color: var(--accent); -} /* Terminal theme button: only when hovering the panel */ .term-theme-btn { From 2b3f48705df319923add37b98e6d20d9cbb04ec1 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:25:23 +0200 Subject: [PATCH 14/25] =?UTF-8?q?feat:=20added=20multi-tab=20terminal=20hu?= =?UTF-8?q?b=20=E2=80=94=20new=20terminals=20open=20inside=20the=20panel?= =?UTF-8?q?=20with=20+=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/panels/terminal/definition.ts | 4 +- src/panels/terminal/terminalHub.ts | 136 +++++++++++++++++++++++++++++ src/styles.css | 76 ++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/panels/terminal/terminalHub.ts diff --git a/src/panels/terminal/definition.ts b/src/panels/terminal/definition.ts index a3a0915..cad7c41 100644 --- a/src/panels/terminal/definition.ts +++ b/src/panels/terminal/definition.ts @@ -8,8 +8,8 @@ export function terminalPanelDefinition(store: AgentStore): PanelDefinition { type: 'terminal', title: appT('panelTerminal'), create: (ctx) => lazyPanel(async () => { - const { createTerminalPanel } = await import('./TerminalPanel') - return createTerminalPanel(ctx.panelId, ctx.projectPath, ctx.removeSelf, undefined, store) + const { createTerminalHub } = await import('./terminalHub') + return createTerminalHub(ctx.panelId, ctx.projectPath ?? '', store) }), } } diff --git a/src/panels/terminal/terminalHub.ts b/src/panels/terminal/terminalHub.ts new file mode 100644 index 0000000..9ecd656 --- /dev/null +++ b/src/panels/terminal/terminalHub.ts @@ -0,0 +1,136 @@ +import { icon } from '../../ui/icons' +import { appT } from '../../core/i18n' +import type { PanelInstance, PanelApi } from '../registry' +import type { AgentStore } from '../../core/terminal/agentStore' +import type { TerminalPanelHandle } from './TerminalPanel' + +interface TabEntry { + handle: TerminalPanelHandle + wrapper: HTMLDivElement + tab: HTMLButtonElement +} + +export function createTerminalHub(panelId: string, projectPath: string, store?: AgentStore): PanelInstance { + const root = document.createElement('div') + root.className = 'terminal-hub' + + const tabBar = document.createElement('div') + tabBar.className = 'terminal-hub-bar' + + const content = document.createElement('div') + content.className = 'terminal-hub-content' + + root.append(tabBar, content) + + const tabs: TabEntry[] = [] + let activeIdx = 0 + let readyApi: PanelApi | undefined + let disposed = false + + const activate = (idx: number): void => { + activeIdx = idx + tabs.forEach(({ wrapper, tab }, i) => { + const isActive = i === idx + wrapper.classList.toggle('terminal-hub-active', isActive) + tab.classList.toggle('active', isActive) + }) + tabs[idx]?.handle.focus() + } + + const removeTab = (idx: number): void => { + if (tabs.length <= 1) return + tabs[idx].handle.dispose() + tabs[idx].tab.remove() + tabs[idx].wrapper.remove() + tabs.splice(idx, 1) + activate(Math.min(idx, tabs.length - 1)) + } + + const addTab = (projectDir = projectPath): void => { + const tabIdx = tabs.length + const tabId = `${panelId}-tab-${tabIdx}-${Date.now()}` + + const wrapper = document.createElement('div') + wrapper.className = 'terminal-hub-instance' + content.appendChild(wrapper) + + // Import lazily to stay consistent with how definition.ts loads the panel. + void import('./TerminalPanel').then(({ createTerminalPanel }) => { + if (disposed) return + const handle = createTerminalPanel(tabId, projectDir, undefined, undefined, store) + + wrapper.appendChild(handle.element) + if (readyApi) handle.onReady(readyApi) + handle.onTitleChange(title => { + const textNode = tab.querySelector('.terminal-hub-tab-label') + if (textNode) textNode.textContent = title + }) + tabs[tabIdx].handle = handle + if (tabIdx === activeIdx) { + wrapper.classList.add('terminal-hub-active') + handle.focus() + } + }) + + const tab = document.createElement('button') + tab.type = 'button' + tab.className = 'terminal-hub-tab' + tab.addEventListener('click', () => activate(tabs.findIndex(t => t.tab === tab))) + + const label = document.createElement('span') + label.className = 'terminal-hub-tab-label' + label.textContent = appT('panelTerminal') + + const closeBtn = document.createElement('button') + closeBtn.type = 'button' + closeBtn.className = 'terminal-hub-tab-close' + closeBtn.innerHTML = icon('x') + closeBtn.title = appT('closePanel') + closeBtn.addEventListener('click', e => { + e.stopPropagation() + removeTab(tabs.findIndex(t => t.tab === tab)) + }) + + tab.append(label, closeBtn) + tabBar.insertBefore(tab, addBtn) + + // Placeholder handle until the async import resolves. + tabs.push({ handle: null as unknown as TerminalPanelHandle, wrapper, tab }) + activate(tabIdx) + } + + const addBtn = document.createElement('button') + addBtn.type = 'button' + addBtn.className = 'terminal-hub-add' + addBtn.title = appT('panelTerminal') + addBtn.innerHTML = icon('plus') + addBtn.addEventListener('click', () => addTab()) + tabBar.appendChild(addBtn) + + addTab() + + return { + element: root, + fit: () => tabs[activeIdx]?.handle?.fit?.(), + focus: () => tabs[activeIdx]?.handle?.focus?.(), + dispose: () => { + disposed = true + tabs.forEach(t => t.handle?.dispose?.()) + }, + onTitleChange: cb => { + // Reflect the active tab's title as the panel title. + const update = (title: string) => cb(title) + tabs[activeIdx]?.handle?.onTitleChange(update) + return () => {} + }, + onReady: api => { + readyApi = api + tabs.forEach(t => t.handle?.onReady?.(api)) + }, + onVisibilityChange: visible => { + tabs.forEach(t => t.handle?.fit?.()) + if (visible) tabs[activeIdx]?.handle?.focus?.() + }, + getCwd: () => tabs[activeIdx]?.handle?.getCwd?.(), + } +} diff --git a/src/styles.css b/src/styles.css index 1114049..a1ee159 100644 --- a/src/styles.css +++ b/src/styles.css @@ -517,6 +517,82 @@ html, body, #app { background: transparent !important; } +/* Terminal hub: tab strip + stacked terminal instances */ +.terminal-hub { display: flex; flex-direction: column; height: 100%; overflow: hidden; } + +.terminal-hub-bar { + display: flex; + align-items: center; + flex-shrink: 0; + height: 30px; + background: var(--surface); + border-bottom: 1px solid var(--border); + overflow-x: auto; + scrollbar-width: none; +} +.terminal-hub-bar::-webkit-scrollbar { display: none; } + +.terminal-hub-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px 0 10px; + height: 100%; + background: transparent; + border: none; + border-right: 1px solid var(--border); + color: var(--fg-dim); + font-size: 11px; + white-space: nowrap; + cursor: pointer; + flex-shrink: 0; +} +.terminal-hub-tab.active { background: var(--surface-2); color: var(--fg); } +.terminal-hub-tab:hover:not(.active) { background: var(--surface-hover); color: var(--fg); } + +.terminal-hub-tab-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; } + +.terminal-hub-tab-close { + display: flex; + align-items: center; + padding: 1px; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + opacity: 0.4; + border-radius: 2px; +} +.terminal-hub-tab-close:hover { opacity: 1; background: var(--surface-3); } +.terminal-hub-tab-close svg { width: 10px; height: 10px; } + +.terminal-hub-add { + display: flex; + align-items: center; + padding: 0 8px; + height: 100%; + background: transparent; + border: none; + color: var(--fg-dim); + cursor: pointer; + flex-shrink: 0; +} +.terminal-hub-add:hover { color: var(--fg); } +.terminal-hub-add svg { width: 12px; height: 12px; } + +.terminal-hub-content { position: relative; flex: 1; min-height: 0; } + +.terminal-hub-instance { + position: absolute; + inset: 0; + visibility: hidden; + pointer-events: none; +} +.terminal-hub-instance.terminal-hub-active { + visibility: visible; + pointer-events: auto; +} + /* Terminal: grid on the wrapper, xterm canvas sits on top */ .terminal-panel { background: From 5bcac3bd419fdc3b85fba10bf586b7435039e64b Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:28:10 +0200 Subject: [PATCH 15/25] =?UTF-8?q?fix:=20made=20terminal=20hub=20+=20button?= =?UTF-8?q?=20visible=20=E2=80=94=20text=20instead=20of=20dim=20SVG=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/panels/terminal/terminalHub.ts | 4 ++-- src/styles.css | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/panels/terminal/terminalHub.ts b/src/panels/terminal/terminalHub.ts index 9ecd656..0824cd3 100644 --- a/src/panels/terminal/terminalHub.ts +++ b/src/panels/terminal/terminalHub.ts @@ -102,8 +102,8 @@ export function createTerminalHub(panelId: string, projectPath: string, store?: const addBtn = document.createElement('button') addBtn.type = 'button' addBtn.className = 'terminal-hub-add' - addBtn.title = appT('panelTerminal') - addBtn.innerHTML = icon('plus') + addBtn.title = 'Nueva terminal' + addBtn.textContent = '+' addBtn.addEventListener('click', () => addTab()) tabBar.appendChild(addBtn) diff --git a/src/styles.css b/src/styles.css index a1ee159..0915e71 100644 --- a/src/styles.css +++ b/src/styles.css @@ -569,16 +569,18 @@ html, body, #app { .terminal-hub-add { display: flex; align-items: center; - padding: 0 8px; + padding: 0 10px; height: 100%; background: transparent; border: none; - color: var(--fg-dim); + border-right: 1px solid var(--border); + color: var(--fg); + font-size: 16px; + line-height: 1; cursor: pointer; flex-shrink: 0; } -.terminal-hub-add:hover { color: var(--fg); } -.terminal-hub-add svg { width: 12px; height: 12px; } +.terminal-hub-add:hover { color: var(--accent); } .terminal-hub-content { position: relative; flex: 1; min-height: 0; } From f65ae53f08eafca35545c0250d6982afadc45b50 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:30:31 +0200 Subject: [PATCH 16/25] =?UTF-8?q?fix:=20terminal=20hub=20bar=20now=20absol?= =?UTF-8?q?ute-positioned=20=E2=80=94=20visible=20regardless=20of=20flex?= =?UTF-8?q?=20height=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/styles.css | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/styles.css b/src/styles.css index 0915e71..5e2671e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -518,14 +518,16 @@ html, body, #app { } /* Terminal hub: tab strip + stacked terminal instances */ -.terminal-hub { display: flex; flex-direction: column; height: 100%; overflow: hidden; } +.terminal-hub { position: relative; width: 100%; height: 100%; } .terminal-hub-bar { + position: absolute; + top: 0; left: 0; right: 0; + height: 30px; + z-index: 1; display: flex; align-items: center; - flex-shrink: 0; - height: 30px; - background: var(--surface); + background: var(--surface-2); border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; @@ -582,7 +584,7 @@ html, body, #app { } .terminal-hub-add:hover { color: var(--accent); } -.terminal-hub-content { position: relative; flex: 1; min-height: 0; } +.terminal-hub-content { position: absolute; top: 30px; left: 0; right: 0; bottom: 0; } .terminal-hub-instance { position: absolute; From eb07e7183f5f4c0a7643ea3135171065dfb4ecb4 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:35:01 +0200 Subject: [PATCH 17/25] =?UTF-8?q?feat:=20+=20button=20inside=20terminal=20?= =?UTF-8?q?panel=20opens=20new=20terminal=20=E2=80=94=20tabs=20appear=20wh?= =?UTF-8?q?en=202+=20open=20in=20same=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/createWorkspaceView.ts | 7 ++++++- src/panels/registry.ts | 2 ++ src/panels/terminal/TerminalPanel.ts | 11 ++++++++++- src/panels/terminal/definition.ts | 4 ++-- src/styles.css | 11 +++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/app/createWorkspaceView.ts b/src/app/createWorkspaceView.ts index 68c9e4e..3c9c9ca 100644 --- a/src/app/createWorkspaceView.ts +++ b/src/app/createWorkspaceView.ts @@ -79,7 +79,12 @@ export function createWorkspaceView(panels: PanelRegistry, options: WorkspaceOpt const def = panels.get(name) if (!def) throw new Error(appT('panelNotRegistered', { name })) - const instance = def.create({ panelId: id, removeSelf: () => removePanel(id), projectPath: options.projectPath?.() }) + const instance = def.create({ + panelId: id, + removeSelf: () => removePanel(id), + projectPath: options.projectPath?.(), + newSibling: () => addPanel(name, { referencePanel: id, direction: 'within' }), + }) instanceMap.set(id, instance) fits.add(instance.fit ?? (() => {})) diff --git a/src/panels/registry.ts b/src/panels/registry.ts index 2e8a9c6..686de9b 100644 --- a/src/panels/registry.ts +++ b/src/panels/registry.ts @@ -8,6 +8,8 @@ export interface PanelContext { removeSelf: () => void // The session's project folder; new terminals start here projectPath?: string + // Opens a new panel of the same type in the same group + newSibling?: () => void } export interface PanelApi { diff --git a/src/panels/terminal/TerminalPanel.ts b/src/panels/terminal/TerminalPanel.ts index 8c6268b..84390a0 100644 --- a/src/panels/terminal/TerminalPanel.ts +++ b/src/panels/terminal/TerminalPanel.ts @@ -59,7 +59,7 @@ export interface TerminalPanelHandle { const DEFAULT_FONT_FAMILY = '"JetBrainsMono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Hack Nerd Font", "CaskaydiaCove Nerd Font", "Symbols Nerd Font", "JetBrains Mono", "Cascadia Code", "Fira Code", Menlo, Monaco, monospace' -export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () => void, execCommand?: string[], store?: AgentStore): TerminalPanelHandle { +export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () => void, execCommand?: string[], store?: AgentStore, newSibling?: () => void): TerminalPanelHandle { const root = document.createElement('div') root.className = 'terminal-panel' @@ -500,6 +500,15 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () popover.appendChild(profiles.element) } + if (newSibling) { + const addBtn = document.createElement('button') + addBtn.className = 'term-theme-btn term-add-btn' + addBtn.title = 'Nueva terminal' + addBtn.textContent = '+' + addBtn.addEventListener('click', () => newSibling()) + root.appendChild(addBtn) + } + root.appendChild(maxBtn) // Run a command here: focus and write it + Enter. If the shell is still diff --git a/src/panels/terminal/definition.ts b/src/panels/terminal/definition.ts index cad7c41..2cf02d5 100644 --- a/src/panels/terminal/definition.ts +++ b/src/panels/terminal/definition.ts @@ -8,8 +8,8 @@ export function terminalPanelDefinition(store: AgentStore): PanelDefinition { type: 'terminal', title: appT('panelTerminal'), create: (ctx) => lazyPanel(async () => { - const { createTerminalHub } = await import('./terminalHub') - return createTerminalHub(ctx.panelId, ctx.projectPath ?? '', store) + const { createTerminalPanel } = await import('./TerminalPanel') + return createTerminalPanel(ctx.panelId, ctx.projectPath, ctx.removeSelf, undefined, store, ctx.newSibling) }), } } diff --git a/src/styles.css b/src/styles.css index 5e2671e..a7569db 100644 --- a/src/styles.css +++ b/src/styles.css @@ -490,6 +490,9 @@ html, body, #app { .workspace-view .dv-groupview > .dv-tabs-and-actions-container { display: none; } +.workspace-view .dv-groupview:has(.dv-tab ~ .dv-tab) > .dv-tabs-and-actions-container { + display: flex; +} .panel-placeholder { display: flex; @@ -722,6 +725,14 @@ html, body, #app { right: 44px; } +.term-add-btn { + bottom: 8px; + right: 80px; + font-size: 18px; + font-weight: 300; + line-height: 1; +} + /* Profiles section in the popover */ .term-profiles-section { border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); From 1b81e59c683681d78a069acf01413aebe502fa27 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:39:46 +0200 Subject: [PATCH 18/25] feat: right-click inside agent terminal shows new agent option --- src/panels/agents/AgentsPanel.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index 594dfc8..76ba9aa 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -6,6 +6,7 @@ import { createTerminalPanel, type TerminalPanelHandle } from '../terminal/Termi import { detectAgentCmd, resolveAgentIdentity } from './detectAgent' import { emitAgentDock, savedAgentDockEntries, type AgentAttention } from '../../core/terminal/agentDockState' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' +import { showContextMenu } from '../../ui/contextMenu' const MAX_AGENTS = 20 @@ -229,6 +230,12 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // ── Terminal area ────────────────────────────────────────────── const termArea = document.createElement('div') termArea.className = 'agents-term-area' + termArea.addEventListener('contextmenu', e => { + e.preventDefault() + showContextMenu(e.clientX, e.clientY, [ + { label: i18nT('agents.newAgent'), onClick: () => addAgent() }, + ]) + }) const emptyMsg = document.createElement('div') emptyMsg.className = 'agents-hub-empty' From 1db71b3dac239f89c03e25ff05771fa439adde15 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:42:30 +0200 Subject: [PATCH 19/25] =?UTF-8?q?feat:=20right-click=20new=20agent=20opens?= =?UTF-8?q?=20split=20view=20=E2=80=94=20both=20terminals=20side=20by=20si?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/panels/agents/AgentsPanel.ts | 34 +++++++++++++++++++++++++++++--- src/styles.css | 16 +++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index 76ba9aa..3463668 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -90,6 +90,7 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { const store = createAgentStore() const slots: AgentSlot[] = [] let activeIndex = -1 + let splitIndex = -1 let agentCounter = 0 let isEditing = false let initialized = false @@ -233,7 +234,7 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { termArea.addEventListener('contextmenu', e => { e.preventDefault() showContextMenu(e.clientX, e.clientY, [ - { label: i18nT('agents.newAgent'), onClick: () => addAgent() }, + { label: i18nT('agents.newAgent'), onClick: () => addAgentAtSide() }, ]) }) @@ -387,7 +388,15 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // ── Activate agent by index ──────────────────────────────────── // Does NOT call renderSidebar — only patches the CSS active class so that // the existing nameEl DOM nodes stay connected (required for dblclick → rename). + const clearSplit = () => { + if (splitIndex < 0) return + splitIndex = -1 + termArea.classList.remove('agents-split') + slots.forEach(s => s.slot.classList.remove('split-primary', 'split-secondary')) + } + const activateAgent = (index: number) => { + clearSplit() if (activeIndex >= 0 && slots[activeIndex]) { slots[activeIndex].slot.classList.remove('active') } @@ -531,10 +540,29 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { renderSidebar() } + const addAgentAtSide = () => { + const primaryIdx = activeIndex + addAgent() + const secondaryIdx = slots.length - 1 + if (primaryIdx < 0 || secondaryIdx === primaryIdx) return + // addAgent called activateAgent which cleared split — now set it up + activeIndex = primaryIdx + splitIndex = secondaryIdx + slots.forEach(s => s.slot.classList.remove('active', 'split-primary', 'split-secondary')) + slots[primaryIdx].slot.classList.add('split-primary') + slots[secondaryIdx].slot.classList.add('split-secondary') + termArea.classList.add('agents-split') + emptyMsg.hidden = true + setTimeout(() => { slots[primaryIdx].handle.fit?.(); slots[secondaryIdx].handle.fit?.() }, 50) + } + // ── Fit ─────────────────────────────────────────────────────── const fit = () => { - if (activeIndex >= 0 && slots[activeIndex]) { - slots[activeIndex].handle.fit?.() + if (splitIndex >= 0) { + slots[activeIndex]?.handle.fit?.() + slots[splitIndex]?.handle.fit?.() + } else if (activeIndex >= 0) { + slots[activeIndex]?.handle.fit?.() } } diff --git a/src/styles.css b/src/styles.css index a7569db..04acf7d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2236,6 +2236,22 @@ html, body, #app { pointer-events: auto; } +.agents-term-area.agents-split { display: flex; } + +.agents-term-area.agents-split .agents-term-slot.split-primary, +.agents-term-area.agents-split .agents-term-slot.split-secondary { + position: relative; + inset: auto; + flex: 1; + height: 100%; + visibility: visible; + pointer-events: auto; +} + +.agents-term-area.agents-split .agents-term-slot.split-primary { + border-right: 1px solid var(--border); +} + .agents-term-slot .terminal-panel { height: 100%; width: 100%; From cd63c7811ec846371fc903ae14a7d380624074db Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 08:44:20 +0200 Subject: [PATCH 20/25] fix: focus new agent terminal after split --- src/panels/agents/AgentsPanel.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index 3463668..c6631da 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -553,7 +553,11 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { slots[secondaryIdx].slot.classList.add('split-secondary') termArea.classList.add('agents-split') emptyMsg.hidden = true - setTimeout(() => { slots[primaryIdx].handle.fit?.(); slots[secondaryIdx].handle.fit?.() }, 50) + setTimeout(() => { + slots[primaryIdx].handle.fit?.() + slots[secondaryIdx].handle.fit?.() + slots[secondaryIdx].handle.focus?.() + }, 80) } // ── Fit ─────────────────────────────────────────────────────── From 339cc162713db77a5a08b61c3dbb00535b0b5344 Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 09:03:18 +0200 Subject: [PATCH 21/25] feat: treat split agents as a group in sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking either member of a split pair no longer destroys the split — it just focuses that terminal. The pair is visually connected in the sidebar with a left accent border. Removing a group member clears the split and adjusts the secondary index after the splice. --- src/panels/agents/AgentsPanel.ts | 38 +++++++++++++++++++++----------- src/styles.css | 9 ++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index c6631da..3065ef1 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -309,6 +309,8 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { const li = document.createElement('li') li.className = `agents-sidebar-item${isActive ? ' active' : ''}${slot.exited ? ' exited' : ''}` + if (splitIndex >= 0 && i === activeIndex) li.classList.add('agents-sidebar-group-primary') + else if (splitIndex >= 0 && i === splitIndex) li.classList.add('agents-sidebar-group-secondary') li.dataset.status = entry.status const att = attention.get(entry.id) if (att) li.dataset.attention = att @@ -396,25 +398,33 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { } const activateAgent = (index: number) => { - clearSplit() - if (activeIndex >= 0 && slots[activeIndex]) { - slots[activeIndex].slot.classList.remove('active') - } - activeIndex = index - if (slots[index]) { - emptyMsg.hidden = true - slots[index].slot.classList.add('active') - slots[index].handle.fit?.() - slots[index].handle.focus?.() + const isInSplitGroup = splitIndex >= 0 && (index === activeIndex || index === splitIndex) + + if (!isInSplitGroup) { + clearSplit() + if (activeIndex >= 0 && slots[activeIndex]) { + slots[activeIndex].slot.classList.remove('active') + } + activeIndex = index + if (slots[index]) { + emptyMsg.hidden = true + slots[index].slot.classList.add('active') + slots[index].handle.fit?.() + slots[index].handle.focus?.() + } + } else { + // Clicking a split group member: keep the split, just focus that terminal + slots[index]?.handle.fit?.() + slots[index]?.handle.focus?.() } + // Viewing an agent clears its attention flag. activateAgent must not call // renderSidebar (it would detach nameEl mid-dblclick), so patch in place. const activeId = slots[index]?.handle.getPtyId() if (activeId) attention.delete(activeId) cs.list.querySelectorAll('.agents-sidebar-item').forEach((li, i) => { - const isActive = i === index - li.classList.toggle('active', isActive) - if (isActive) { + li.classList.toggle('active', i === index) + if (i === index) { li.removeAttribute('data-attention') li.querySelector('.agents-sidebar-badge')?.remove() } @@ -524,12 +534,14 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // ── Remove agent ────────────────────────────────────────────── const removeAgent = (index: number) => { if (index < 0 || index >= slots.length) return + if (splitIndex >= 0 && (index === activeIndex || index === splitIndex)) clearSplit() const s = slots[index] attention.delete(s.handle.getPtyId()) s.titleCleanup() s.handle.dispose?.() s.slot.remove() slots.splice(index, 1) + if (splitIndex > index) splitIndex-- if (slots.length === 0) { activeIndex = -1 diff --git a/src/styles.css b/src/styles.css index 04acf7d..6edfe7c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2252,6 +2252,15 @@ html, body, #app { border-right: 1px solid var(--border); } +/* Split group indicator in sidebar */ +.agents-sidebar-item.agents-sidebar-group-primary, +.agents-sidebar-item.agents-sidebar-group-secondary { + border-left: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); + padding-left: 6px; +} +.agents-sidebar-item.agents-sidebar-group-primary { border-radius: var(--radius) var(--radius) 0 0; margin-bottom: 0; } +.agents-sidebar-item.agents-sidebar-group-secondary { border-radius: 0 0 var(--radius) var(--radius); } + .agents-term-slot .terminal-panel { height: 100%; width: 100%; From 6ade02614f76575b78e968694b23360f66340e4e Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 09:10:40 +0200 Subject: [PATCH 22/25] feat: N-way directional splits in agents panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click now shows 4 directions (←→↑↓). Each adds a new terminal relative to the one under the cursor. Adding in the same direction as the existing split extends the group; adding perpendicular clears the old split and starts a new one. Closing a member shrinks the group gracefully. CSS uses flex order to reposition members without DOM reorder, so position:absolute non-members stay out of flex flow. --- src/i18n/en.json | 6 +- src/i18n/es.json | 6 +- src/panels/agents/AgentsPanel.ts | 118 ++++++++++++++++++++++--------- src/styles.css | 22 +++--- 4 files changed, 108 insertions(+), 44 deletions(-) diff --git a/src/i18n/en.json b/src/i18n/en.json index a755621..5a9b86b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -601,7 +601,11 @@ "terminalsCount": "{count} agents", "newAgent": "New Agent", "waitingForInput": "Waiting for input", - "wantsAttention": "Wants your attention" + "wantsAttention": "Wants your attention", + "splitLeft": "Split left", + "splitRight": "Split right", + "splitAbove": "Split above", + "splitBelow": "Split below" }, "db": { "connecting": "Connecting…", diff --git a/src/i18n/es.json b/src/i18n/es.json index 98a09d3..b64699d 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -601,7 +601,11 @@ "terminalsCount": "{count} agentes", "newAgent": "Nuevo agente", "waitingForInput": "Esperando entrada", - "wantsAttention": "Requiere tu atención" + "wantsAttention": "Requiere tu atención", + "splitLeft": "Dividir a la izquierda", + "splitRight": "Dividir a la derecha", + "splitAbove": "Dividir arriba", + "splitBelow": "Dividir abajo" }, "db": { "connecting": "Conectando…", diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index 3065ef1..ab49f7b 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -90,7 +90,9 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { const store = createAgentStore() const slots: AgentSlot[] = [] let activeIndex = -1 - let splitIndex = -1 + // Ordered indices of the terminals currently visible in split view (null = single terminal mode). + let splitGroup: number[] | null = null + let splitDir: 'h' | 'v' = 'h' let agentCounter = 0 let isEditing = false let initialized = false @@ -233,8 +235,13 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { termArea.className = 'agents-term-area' termArea.addEventListener('contextmenu', e => { e.preventDefault() + const slotEl = (e.target as HTMLElement).closest('.agents-term-slot') + const refIdx = slotEl ? (slots.findIndex(s => s.slot === slotEl) ?? activeIndex) : activeIndex showContextMenu(e.clientX, e.clientY, [ - { label: i18nT('agents.newAgent'), onClick: () => addAgentAtSide() }, + { label: `← ${i18nT('agents.splitLeft')}`, onClick: () => addAgentAtSide('left', refIdx) }, + { label: `→ ${i18nT('agents.splitRight')}`, onClick: () => addAgentAtSide('right', refIdx) }, + { label: `↑ ${i18nT('agents.splitAbove')}`, onClick: () => addAgentAtSide('top', refIdx) }, + { label: `↓ ${i18nT('agents.splitBelow')}`, onClick: () => addAgentAtSide('bottom',refIdx) }, ]) }) @@ -309,8 +316,7 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { const li = document.createElement('li') li.className = `agents-sidebar-item${isActive ? ' active' : ''}${slot.exited ? ' exited' : ''}` - if (splitIndex >= 0 && i === activeIndex) li.classList.add('agents-sidebar-group-primary') - else if (splitIndex >= 0 && i === splitIndex) li.classList.add('agents-sidebar-group-secondary') + if (splitGroup?.includes(i)) li.classList.add('agents-sidebar-group-member') li.dataset.status = entry.status const att = attention.get(entry.id) if (att) li.dataset.attention = att @@ -391,14 +397,27 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // Does NOT call renderSidebar — only patches the CSS active class so that // the existing nameEl DOM nodes stay connected (required for dblclick → rename). const clearSplit = () => { - if (splitIndex < 0) return - splitIndex = -1 - termArea.classList.remove('agents-split') - slots.forEach(s => s.slot.classList.remove('split-primary', 'split-secondary')) + if (!splitGroup) return + splitGroup = null + termArea.classList.remove('agents-split-h', 'agents-split-v') + slots.forEach(s => { s.slot.classList.remove('split-member'); s.slot.style.order = '' }) + } + + const applySplit = () => { + if (!splitGroup || splitGroup.length < 2) { clearSplit(); return } + termArea.classList.remove('agents-split-h', 'agents-split-v') + termArea.classList.add(`agents-split-${splitDir}`) + slots.forEach(s => { s.slot.classList.remove('split-member', 'active'); s.slot.style.order = '' }) + splitGroup.forEach((idx, order) => { + if (!slots[idx]) return + slots[idx].slot.classList.add('split-member') + slots[idx].slot.style.order = String(order) + }) + emptyMsg.hidden = true } const activateAgent = (index: number) => { - const isInSplitGroup = splitIndex >= 0 && (index === activeIndex || index === splitIndex) + const isInSplitGroup = splitGroup?.includes(index) ?? false if (!isInSplitGroup) { clearSplit() @@ -413,7 +432,8 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { slots[index].handle.focus?.() } } else { - // Clicking a split group member: keep the split, just focus that terminal + // Split group member: keep the split, just focus that terminal + activeIndex = index slots[index]?.handle.fit?.() slots[index]?.handle.focus?.() } @@ -534,49 +554,83 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // ── Remove agent ────────────────────────────────────────────── const removeAgent = (index: number) => { if (index < 0 || index >= slots.length) return - if (splitIndex >= 0 && (index === activeIndex || index === splitIndex)) clearSplit() + + // Adjust splitGroup before the splice + if (splitGroup) { + if (splitGroup.includes(index)) { + const next = splitGroup.filter(i => i !== index).map(i => i > index ? i - 1 : i) + if (next.length >= 2) { + splitGroup = next + } else { + // Group dissolves: clear CSS before removing slot + termArea.classList.remove('agents-split-h', 'agents-split-v') + slots.forEach(s => { s.slot.classList.remove('split-member'); s.slot.style.order = '' }) + splitGroup = null + } + } else { + splitGroup = splitGroup.map(i => i > index ? i - 1 : i) + } + } + + // Adjust activeIndex before the splice + if (activeIndex > index) activeIndex-- + else if (activeIndex === index) activeIndex = Math.max(0, index - 1) + const s = slots[index] attention.delete(s.handle.getPtyId()) s.titleCleanup() s.handle.dispose?.() s.slot.remove() slots.splice(index, 1) - if (splitIndex > index) splitIndex-- if (slots.length === 0) { activeIndex = -1 + splitGroup = null emptyMsg.hidden = false + } else if (splitGroup) { + if (!splitGroup.includes(activeIndex)) activeIndex = splitGroup[0] + applySplit() + slots[activeIndex]?.handle.fit?.() + slots[activeIndex]?.handle.focus?.() } else { - activateAgent(Math.min(index, slots.length - 1)) + activateAgent(activeIndex) } renderSidebar() } - const addAgentAtSide = () => { - const primaryIdx = activeIndex - addAgent() - const secondaryIdx = slots.length - 1 - if (primaryIdx < 0 || secondaryIdx === primaryIdx) return - // addAgent called activateAgent which cleared split — now set it up - activeIndex = primaryIdx - splitIndex = secondaryIdx - slots.forEach(s => s.slot.classList.remove('active', 'split-primary', 'split-secondary')) - slots[primaryIdx].slot.classList.add('split-primary') - slots[secondaryIdx].slot.classList.add('split-secondary') - termArea.classList.add('agents-split') - emptyMsg.hidden = true + const addAgentAtSide = (dir: 'left' | 'right' | 'top' | 'bottom', refIdx = activeIndex) => { + const newDir: 'h' | 'v' = (dir === 'left' || dir === 'right') ? 'h' : 'v' + const insertAfter = (dir === 'right' || dir === 'bottom') + + // Perpendicular to current group → clear and start a new split + if (splitGroup && splitDir !== newDir) clearSplit() + + addAgent() // internally calls activateAgent(newIdx) → clearSplit() + const newIdx = slots.length - 1 + if (newIdx === refIdx) return // only 1 slot (shouldn't happen) + + if (!splitGroup) { + splitGroup = insertAfter ? [refIdx, newIdx] : [newIdx, refIdx] + splitDir = newDir + } else { + const pos = splitGroup.indexOf(refIdx) + const insertAt = pos < 0 ? splitGroup.length : (insertAfter ? pos + 1 : pos) + splitGroup.splice(insertAt, 0, newIdx) + } + + activeIndex = refIdx + applySplit() + setTimeout(() => { - slots[primaryIdx].handle.fit?.() - slots[secondaryIdx].handle.fit?.() - slots[secondaryIdx].handle.focus?.() + splitGroup!.forEach(idx => slots[idx]?.handle.fit?.()) + slots[newIdx]?.handle.focus?.() }, 80) } // ── Fit ─────────────────────────────────────────────────────── const fit = () => { - if (splitIndex >= 0) { - slots[activeIndex]?.handle.fit?.() - slots[splitIndex]?.handle.fit?.() + if (splitGroup) { + splitGroup.forEach(idx => slots[idx]?.handle.fit?.()) } else if (activeIndex >= 0) { slots[activeIndex]?.handle.fit?.() } diff --git a/src/styles.css b/src/styles.css index 6edfe7c..b22144e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2236,30 +2236,32 @@ html, body, #app { pointer-events: auto; } -.agents-term-area.agents-split { display: flex; } +.agents-term-area.agents-split-h { display: flex; flex-direction: row; } +.agents-term-area.agents-split-v { display: flex; flex-direction: column; } -.agents-term-area.agents-split .agents-term-slot.split-primary, -.agents-term-area.agents-split .agents-term-slot.split-secondary { +.agents-term-area.agents-split-h .agents-term-slot.split-member, +.agents-term-area.agents-split-v .agents-term-slot.split-member { position: relative; inset: auto; flex: 1; - height: 100%; + min-width: 0; + min-height: 0; visibility: visible; pointer-events: auto; } -.agents-term-area.agents-split .agents-term-slot.split-primary { - border-right: 1px solid var(--border); +.agents-term-area.agents-split-h .agents-term-slot.split-member + .agents-term-slot.split-member { + border-left: 1px solid var(--border); +} +.agents-term-area.agents-split-v .agents-term-slot.split-member + .agents-term-slot.split-member { + border-top: 1px solid var(--border); } /* Split group indicator in sidebar */ -.agents-sidebar-item.agents-sidebar-group-primary, -.agents-sidebar-item.agents-sidebar-group-secondary { +.agents-sidebar-item.agents-sidebar-group-member { border-left: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); padding-left: 6px; } -.agents-sidebar-item.agents-sidebar-group-primary { border-radius: var(--radius) var(--radius) 0 0; margin-bottom: 0; } -.agents-sidebar-item.agents-sidebar-group-secondary { border-radius: 0 0 var(--radius) var(--radius); } .agents-term-slot .terminal-panel { height: 100%; From c5fdef1f03c051c84114112b0b66311a11e2b91b Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 09:48:01 +0200 Subject: [PATCH 23/25] fix: preserve split group when creating agent from sidebar button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a new agent via the sidebar footer no longer destroys the active split — the new agent is registered in the list and the user can switch to it or split from it from there. Also re-renders sidebar after each directional split to refresh group member indicators. --- src/panels/agents/AgentsPanel.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index ab49f7b..41c47b2 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -536,7 +536,13 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { slots.push(agentSlot) - activateAgent(slots.length - 1) + // If a split group is active, don't disrupt it: the new agent goes to the + // sidebar and the user can switch to it (or split it) from there. + if (splitGroup) { + renderSidebar() + } else { + activateAgent(slots.length - 1) + } } // ── Process exited ──────────────────────────────────────────── @@ -620,6 +626,7 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { activeIndex = refIdx applySplit() + renderSidebar() setTimeout(() => { splitGroup!.forEach(idx => slots[idx]?.handle.fit?.()) From 95f9bf124e38168411b7c69332c7333e8f5afdbe Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 09:58:54 +0200 Subject: [PATCH 24/25] refactor: remove split view, color and maximize buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One agent at a time in the agents panel — no split logic. Removed the color-picker (themeButton + popover) and maximize button from the terminal panel. Also deleted the dead terminalHub.ts. --- src/i18n/en.json | 6 +- src/i18n/es.json | 6 +- src/panels/agents/AgentsPanel.ts | 143 ++----------------- src/panels/terminal/TerminalPanel.ts | 88 +----------- src/panels/terminal/definition.ts | 2 +- src/panels/terminal/terminalHub.ts | 136 ------------------- src/styles.css | 196 +-------------------------- 7 files changed, 22 insertions(+), 555 deletions(-) delete mode 100644 src/panels/terminal/terminalHub.ts diff --git a/src/i18n/en.json b/src/i18n/en.json index 5a9b86b..a755621 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -601,11 +601,7 @@ "terminalsCount": "{count} agents", "newAgent": "New Agent", "waitingForInput": "Waiting for input", - "wantsAttention": "Wants your attention", - "splitLeft": "Split left", - "splitRight": "Split right", - "splitAbove": "Split above", - "splitBelow": "Split below" + "wantsAttention": "Wants your attention" }, "db": { "connecting": "Connecting…", diff --git a/src/i18n/es.json b/src/i18n/es.json index b64699d..98a09d3 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -601,11 +601,7 @@ "terminalsCount": "{count} agentes", "newAgent": "Nuevo agente", "waitingForInput": "Esperando entrada", - "wantsAttention": "Requiere tu atención", - "splitLeft": "Dividir a la izquierda", - "splitRight": "Dividir a la derecha", - "splitAbove": "Dividir arriba", - "splitBelow": "Dividir abajo" + "wantsAttention": "Requiere tu atención" }, "db": { "connecting": "Conectando…", diff --git a/src/panels/agents/AgentsPanel.ts b/src/panels/agents/AgentsPanel.ts index 41c47b2..62d36dc 100644 --- a/src/panels/agents/AgentsPanel.ts +++ b/src/panels/agents/AgentsPanel.ts @@ -6,7 +6,6 @@ import { createTerminalPanel, type TerminalPanelHandle } from '../terminal/Termi import { detectAgentCmd, resolveAgentIdentity } from './detectAgent' import { emitAgentDock, savedAgentDockEntries, type AgentAttention } from '../../core/terminal/agentDockState' import { createCollapsibleSidebar } from '../../ui/collapsibleSidebar' -import { showContextMenu } from '../../ui/contextMenu' const MAX_AGENTS = 20 @@ -90,9 +89,6 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { const store = createAgentStore() const slots: AgentSlot[] = [] let activeIndex = -1 - // Ordered indices of the terminals currently visible in split view (null = single terminal mode). - let splitGroup: number[] | null = null - let splitDir: 'h' | 'v' = 'h' let agentCounter = 0 let isEditing = false let initialized = false @@ -233,17 +229,6 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // ── Terminal area ────────────────────────────────────────────── const termArea = document.createElement('div') termArea.className = 'agents-term-area' - termArea.addEventListener('contextmenu', e => { - e.preventDefault() - const slotEl = (e.target as HTMLElement).closest('.agents-term-slot') - const refIdx = slotEl ? (slots.findIndex(s => s.slot === slotEl) ?? activeIndex) : activeIndex - showContextMenu(e.clientX, e.clientY, [ - { label: `← ${i18nT('agents.splitLeft')}`, onClick: () => addAgentAtSide('left', refIdx) }, - { label: `→ ${i18nT('agents.splitRight')}`, onClick: () => addAgentAtSide('right', refIdx) }, - { label: `↑ ${i18nT('agents.splitAbove')}`, onClick: () => addAgentAtSide('top', refIdx) }, - { label: `↓ ${i18nT('agents.splitBelow')}`, onClick: () => addAgentAtSide('bottom',refIdx) }, - ]) - }) const emptyMsg = document.createElement('div') emptyMsg.className = 'agents-hub-empty' @@ -316,7 +301,6 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { const li = document.createElement('li') li.className = `agents-sidebar-item${isActive ? ' active' : ''}${slot.exited ? ' exited' : ''}` - if (splitGroup?.includes(i)) li.classList.add('agents-sidebar-group-member') li.dataset.status = entry.status const att = attention.get(entry.id) if (att) li.dataset.attention = att @@ -396,55 +380,25 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // ── Activate agent by index ──────────────────────────────────── // Does NOT call renderSidebar — only patches the CSS active class so that // the existing nameEl DOM nodes stay connected (required for dblclick → rename). - const clearSplit = () => { - if (!splitGroup) return - splitGroup = null - termArea.classList.remove('agents-split-h', 'agents-split-v') - slots.forEach(s => { s.slot.classList.remove('split-member'); s.slot.style.order = '' }) - } - - const applySplit = () => { - if (!splitGroup || splitGroup.length < 2) { clearSplit(); return } - termArea.classList.remove('agents-split-h', 'agents-split-v') - termArea.classList.add(`agents-split-${splitDir}`) - slots.forEach(s => { s.slot.classList.remove('split-member', 'active'); s.slot.style.order = '' }) - splitGroup.forEach((idx, order) => { - if (!slots[idx]) return - slots[idx].slot.classList.add('split-member') - slots[idx].slot.style.order = String(order) - }) - emptyMsg.hidden = true - } - const activateAgent = (index: number) => { - const isInSplitGroup = splitGroup?.includes(index) ?? false - - if (!isInSplitGroup) { - clearSplit() - if (activeIndex >= 0 && slots[activeIndex]) { - slots[activeIndex].slot.classList.remove('active') - } - activeIndex = index - if (slots[index]) { - emptyMsg.hidden = true - slots[index].slot.classList.add('active') - slots[index].handle.fit?.() - slots[index].handle.focus?.() - } - } else { - // Split group member: keep the split, just focus that terminal - activeIndex = index - slots[index]?.handle.fit?.() - slots[index]?.handle.focus?.() + if (activeIndex >= 0 && slots[activeIndex]) { + slots[activeIndex].slot.classList.remove('active') + } + activeIndex = index + if (slots[index]) { + emptyMsg.hidden = true + slots[index].slot.classList.add('active') + slots[index].handle.fit?.() + slots[index].handle.focus?.() } - // Viewing an agent clears its attention flag. activateAgent must not call // renderSidebar (it would detach nameEl mid-dblclick), so patch in place. const activeId = slots[index]?.handle.getPtyId() if (activeId) attention.delete(activeId) cs.list.querySelectorAll('.agents-sidebar-item').forEach((li, i) => { - li.classList.toggle('active', i === index) - if (i === index) { + const isActive = i === index + li.classList.toggle('active', isActive) + if (isActive) { li.removeAttribute('data-attention') li.querySelector('.agents-sidebar-badge')?.remove() } @@ -536,13 +490,7 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { slots.push(agentSlot) - // If a split group is active, don't disrupt it: the new agent goes to the - // sidebar and the user can switch to it (or split it) from there. - if (splitGroup) { - renderSidebar() - } else { - activateAgent(slots.length - 1) - } + activateAgent(slots.length - 1) } // ── Process exited ──────────────────────────────────────────── @@ -560,28 +508,6 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { // ── Remove agent ────────────────────────────────────────────── const removeAgent = (index: number) => { if (index < 0 || index >= slots.length) return - - // Adjust splitGroup before the splice - if (splitGroup) { - if (splitGroup.includes(index)) { - const next = splitGroup.filter(i => i !== index).map(i => i > index ? i - 1 : i) - if (next.length >= 2) { - splitGroup = next - } else { - // Group dissolves: clear CSS before removing slot - termArea.classList.remove('agents-split-h', 'agents-split-v') - slots.forEach(s => { s.slot.classList.remove('split-member'); s.slot.style.order = '' }) - splitGroup = null - } - } else { - splitGroup = splitGroup.map(i => i > index ? i - 1 : i) - } - } - - // Adjust activeIndex before the splice - if (activeIndex > index) activeIndex-- - else if (activeIndex === index) activeIndex = Math.max(0, index - 1) - const s = slots[index] attention.delete(s.handle.getPtyId()) s.titleCleanup() @@ -591,56 +517,17 @@ export function createAgentsPanel(projectPath = '', opts: AgentsPanelOptions = { if (slots.length === 0) { activeIndex = -1 - splitGroup = null emptyMsg.hidden = false - } else if (splitGroup) { - if (!splitGroup.includes(activeIndex)) activeIndex = splitGroup[0] - applySplit() - slots[activeIndex]?.handle.fit?.() - slots[activeIndex]?.handle.focus?.() } else { - activateAgent(activeIndex) + activateAgent(Math.min(index, slots.length - 1)) } renderSidebar() } - const addAgentAtSide = (dir: 'left' | 'right' | 'top' | 'bottom', refIdx = activeIndex) => { - const newDir: 'h' | 'v' = (dir === 'left' || dir === 'right') ? 'h' : 'v' - const insertAfter = (dir === 'right' || dir === 'bottom') - - // Perpendicular to current group → clear and start a new split - if (splitGroup && splitDir !== newDir) clearSplit() - - addAgent() // internally calls activateAgent(newIdx) → clearSplit() - const newIdx = slots.length - 1 - if (newIdx === refIdx) return // only 1 slot (shouldn't happen) - - if (!splitGroup) { - splitGroup = insertAfter ? [refIdx, newIdx] : [newIdx, refIdx] - splitDir = newDir - } else { - const pos = splitGroup.indexOf(refIdx) - const insertAt = pos < 0 ? splitGroup.length : (insertAfter ? pos + 1 : pos) - splitGroup.splice(insertAt, 0, newIdx) - } - - activeIndex = refIdx - applySplit() - renderSidebar() - - setTimeout(() => { - splitGroup!.forEach(idx => slots[idx]?.handle.fit?.()) - slots[newIdx]?.handle.focus?.() - }, 80) - } // ── Fit ─────────────────────────────────────────────────────── const fit = () => { - if (splitGroup) { - splitGroup.forEach(idx => slots[idx]?.handle.fit?.()) - } else if (activeIndex >= 0) { - slots[activeIndex]?.handle.fit?.() - } + if (activeIndex >= 0) slots[activeIndex]?.handle.fit?.() } // Save agents synchronously if the page is torn down (reload/close) before a diff --git a/src/panels/terminal/TerminalPanel.ts b/src/panels/terminal/TerminalPanel.ts index 84390a0..c557a38 100644 --- a/src/panels/terminal/TerminalPanel.ts +++ b/src/panels/terminal/TerminalPanel.ts @@ -14,16 +14,12 @@ import { dimsChanged, type Dims } from '../../core/terminal/dims' import { splitAtSyncBoundary } from '../../core/terminal/syncOutput' import { getThemeName, onThemeChange } from './themePreference' import { nextTheme } from '../../core/terminal/nextTheme' -import type { TerminalProfile } from '../../core/terminal/profiles' import { createActivityTracker } from '../../core/terminal/activityTracker' import { createAgentStatusTracker } from '../../core/terminal/agentStatusTracker' import type { AgentStore } from '../../core/terminal/agentStore' import { parseOsc7Path, toDisplayPath } from '../../core/terminal/osc7' import { createSearchBar } from './searchBar' -import { createTerminalAppearanceControls } from './appearanceControls' -import { createTerminalProfileControls } from './profileControls' import { askAi } from '../../ui/askAi' -import { icon } from '../../ui/icons' import type { PanelApi } from '../registry' import 'xterm/css/xterm.css' @@ -47,7 +43,7 @@ export interface TerminalPanelHandle { focus: () => void dispose: () => void onTitleChange: (cb: (title: string) => void) => () => void - onReady: (api: PanelApi) => void + onReady?: (api: PanelApi) => void getCwd: () => string | undefined sendInput: (text: string) => void onInput: (cb: (line: string) => void) => () => void @@ -59,7 +55,7 @@ export interface TerminalPanelHandle { const DEFAULT_FONT_FAMILY = '"JetBrainsMono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Hack Nerd Font", "CaskaydiaCove Nerd Font", "Symbols Nerd Font", "JetBrains Mono", "Cascadia Code", "Fira Code", Menlo, Monaco, monospace' -export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () => void, execCommand?: string[], store?: AgentStore, newSibling?: () => void): TerminalPanelHandle { +export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () => void, execCommand?: string[], store?: AgentStore): TerminalPanelHandle { const root = document.createElement('div') root.className = 'terminal-panel' @@ -131,34 +127,6 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () applyLocalTheme(localTheme) } - const applyCustomBackground = (bg: string) => { - followGlobal = false - const base = getTheme(localTheme) - const custom = { ...base, background: bg, cursorAccent: bg } - term.options.theme = custom - applyBackground(bg) - } - - const appearance = createTerminalAppearanceControls({ - themeName: localTheme, - onThemeSelected: name => { - followGlobal = false - localTheme = name - applyLocalTheme(name) - }, - onCustomBackground: applyCustomBackground, - onShellChanged: shell => { - restartShell(shell) - }, - onFontChanged: font => { - localFontFamily = font.trim() || DEFAULT_FONT_FAMILY - term.options.fontFamily = localFontFamily - fit() - }, - }) - const { popover, themeButton: themeBtn, shellSelect, fontInput } = appearance - root.append(popover, themeBtn) - root.addEventListener('click', () => popover.classList.add('hidden')) const fitAddon = new FitAddon() const searchAddon = new SearchAddon() @@ -203,12 +171,6 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () .catch(err => term.writeln(`\r\n\x1b[31mError PTY: ${err}\x1b[0m`)) } - const restartShell = (shellPath: string): void => { - invoke('pty_kill', { id }).catch(() => {}) - term.reset() - spawnShell(shellPath) - popover.classList.add('hidden') - } spawnShell('auto') @@ -466,50 +428,6 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () return () => { titleCallback = undefined; d1.dispose(); d2.dispose() } } - const maxBtn = document.createElement('button') - maxBtn.className = 'term-theme-btn term-max-btn' - maxBtn.title = i18nT('terminal.maximizeRestore') - maxBtn.innerHTML = icon('expand') - - const onReady = (panelApi: PanelApi) => { - maxBtn.addEventListener('click', () => { - if (panelApi.isMaximized()) panelApi.exitMaximized() - else panelApi.maximize() - }) - - const profiles = createTerminalProfileControls({ - getSettings: () => ({ - shell: shellSelect.value, - theme: localTheme, - fontSize: term.options.fontSize ?? BASE_FONT_SIZE, - fontFamily: localFontFamily !== DEFAULT_FONT_FAMILY ? localFontFamily : undefined, - }), - onSelect: (profile: TerminalProfile) => { - followGlobal = false - localTheme = profile.theme - applyLocalTheme(profile.theme) - setFontSize(profile.fontSize) - if (profile.fontFamily) { - localFontFamily = profile.fontFamily - fontInput.value = profile.fontFamily - term.options.fontFamily = profile.fontFamily - } - restartShell(profile.shell) - }, - }) - popover.appendChild(profiles.element) - } - - if (newSibling) { - const addBtn = document.createElement('button') - addBtn.className = 'term-theme-btn term-add-btn' - addBtn.title = 'Nueva terminal' - addBtn.textContent = '+' - addBtn.addEventListener('click', () => newSibling()) - root.appendChild(addBtn) - } - - root.appendChild(maxBtn) // Run a command here: focus and write it + Enter. If the shell is still // starting up, queue it (early writes get discarded) and flush when ready. @@ -520,7 +438,7 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () } return { - element: root, fit, focus: () => term.focus(), dispose, onTitleChange, onReady, + element: root, fit, focus: () => term.focus(), dispose, onTitleChange, getCwd: () => lastCwd || undefined, sendInput, onInput, onBell, getSnapshot: () => { try { return serializeAddon.serialize({ scrollback: 500 }) } catch { return '' } }, // A malformed/huge restored snapshot must not throw — that would drop the diff --git a/src/panels/terminal/definition.ts b/src/panels/terminal/definition.ts index 2cf02d5..a3a0915 100644 --- a/src/panels/terminal/definition.ts +++ b/src/panels/terminal/definition.ts @@ -9,7 +9,7 @@ export function terminalPanelDefinition(store: AgentStore): PanelDefinition { title: appT('panelTerminal'), create: (ctx) => lazyPanel(async () => { const { createTerminalPanel } = await import('./TerminalPanel') - return createTerminalPanel(ctx.panelId, ctx.projectPath, ctx.removeSelf, undefined, store, ctx.newSibling) + return createTerminalPanel(ctx.panelId, ctx.projectPath, ctx.removeSelf, undefined, store) }), } } diff --git a/src/panels/terminal/terminalHub.ts b/src/panels/terminal/terminalHub.ts deleted file mode 100644 index 0824cd3..0000000 --- a/src/panels/terminal/terminalHub.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { icon } from '../../ui/icons' -import { appT } from '../../core/i18n' -import type { PanelInstance, PanelApi } from '../registry' -import type { AgentStore } from '../../core/terminal/agentStore' -import type { TerminalPanelHandle } from './TerminalPanel' - -interface TabEntry { - handle: TerminalPanelHandle - wrapper: HTMLDivElement - tab: HTMLButtonElement -} - -export function createTerminalHub(panelId: string, projectPath: string, store?: AgentStore): PanelInstance { - const root = document.createElement('div') - root.className = 'terminal-hub' - - const tabBar = document.createElement('div') - tabBar.className = 'terminal-hub-bar' - - const content = document.createElement('div') - content.className = 'terminal-hub-content' - - root.append(tabBar, content) - - const tabs: TabEntry[] = [] - let activeIdx = 0 - let readyApi: PanelApi | undefined - let disposed = false - - const activate = (idx: number): void => { - activeIdx = idx - tabs.forEach(({ wrapper, tab }, i) => { - const isActive = i === idx - wrapper.classList.toggle('terminal-hub-active', isActive) - tab.classList.toggle('active', isActive) - }) - tabs[idx]?.handle.focus() - } - - const removeTab = (idx: number): void => { - if (tabs.length <= 1) return - tabs[idx].handle.dispose() - tabs[idx].tab.remove() - tabs[idx].wrapper.remove() - tabs.splice(idx, 1) - activate(Math.min(idx, tabs.length - 1)) - } - - const addTab = (projectDir = projectPath): void => { - const tabIdx = tabs.length - const tabId = `${panelId}-tab-${tabIdx}-${Date.now()}` - - const wrapper = document.createElement('div') - wrapper.className = 'terminal-hub-instance' - content.appendChild(wrapper) - - // Import lazily to stay consistent with how definition.ts loads the panel. - void import('./TerminalPanel').then(({ createTerminalPanel }) => { - if (disposed) return - const handle = createTerminalPanel(tabId, projectDir, undefined, undefined, store) - - wrapper.appendChild(handle.element) - if (readyApi) handle.onReady(readyApi) - handle.onTitleChange(title => { - const textNode = tab.querySelector('.terminal-hub-tab-label') - if (textNode) textNode.textContent = title - }) - tabs[tabIdx].handle = handle - if (tabIdx === activeIdx) { - wrapper.classList.add('terminal-hub-active') - handle.focus() - } - }) - - const tab = document.createElement('button') - tab.type = 'button' - tab.className = 'terminal-hub-tab' - tab.addEventListener('click', () => activate(tabs.findIndex(t => t.tab === tab))) - - const label = document.createElement('span') - label.className = 'terminal-hub-tab-label' - label.textContent = appT('panelTerminal') - - const closeBtn = document.createElement('button') - closeBtn.type = 'button' - closeBtn.className = 'terminal-hub-tab-close' - closeBtn.innerHTML = icon('x') - closeBtn.title = appT('closePanel') - closeBtn.addEventListener('click', e => { - e.stopPropagation() - removeTab(tabs.findIndex(t => t.tab === tab)) - }) - - tab.append(label, closeBtn) - tabBar.insertBefore(tab, addBtn) - - // Placeholder handle until the async import resolves. - tabs.push({ handle: null as unknown as TerminalPanelHandle, wrapper, tab }) - activate(tabIdx) - } - - const addBtn = document.createElement('button') - addBtn.type = 'button' - addBtn.className = 'terminal-hub-add' - addBtn.title = 'Nueva terminal' - addBtn.textContent = '+' - addBtn.addEventListener('click', () => addTab()) - tabBar.appendChild(addBtn) - - addTab() - - return { - element: root, - fit: () => tabs[activeIdx]?.handle?.fit?.(), - focus: () => tabs[activeIdx]?.handle?.focus?.(), - dispose: () => { - disposed = true - tabs.forEach(t => t.handle?.dispose?.()) - }, - onTitleChange: cb => { - // Reflect the active tab's title as the panel title. - const update = (title: string) => cb(title) - tabs[activeIdx]?.handle?.onTitleChange(update) - return () => {} - }, - onReady: api => { - readyApi = api - tabs.forEach(t => t.handle?.onReady?.(api)) - }, - onVisibilityChange: visible => { - tabs.forEach(t => t.handle?.fit?.()) - if (visible) tabs[activeIdx]?.handle?.focus?.() - }, - getCwd: () => tabs[activeIdx]?.handle?.getCwd?.(), - } -} diff --git a/src/styles.css b/src/styles.css index b22144e..62c0475 100644 --- a/src/styles.css +++ b/src/styles.css @@ -40,8 +40,7 @@ input:focus, textarea:focus, select:focus { } .tv-btn, -.window-control, -.term-theme-btn { +.window-control { display: inline-flex; align-items: center; justify-content: center; @@ -638,165 +637,6 @@ html, body, #app { height: 0; } -.term-theme-btn { - position: absolute; - bottom: 8px; - right: 12px; - z-index: 15; - width: 26px; - height: 26px; - padding: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - font-size: 13px; - cursor: pointer; - opacity: 0.35; - transition: opacity 0.15s; -} - -.term-theme-btn:hover { - opacity: 1; -} - -.term-theme-popover { - position: absolute; - bottom: 42px; - right: 8px; - z-index: 20; - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px; - background: color-mix(in srgb, var(--surface) 90%, transparent); - border: 1px solid var(--border); - border-radius: 10px; - backdrop-filter: blur(16px); - box-shadow: 0 8px 24px rgba(0,0,0,0.4); -} - -.term-theme-popover.hidden { - display: none; -} - -.term-theme-swatches { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 6px; -} - -.term-theme-swatch { - width: 28px; - height: 28px; - border-radius: 50%; - border: 2px solid transparent; - cursor: pointer; - transition: transform 0.1s, border-color 0.1s; -} - -.term-theme-swatch:hover { - transform: scale(1.2); - border-color: var(--fg) !important; -} - -.term-theme-color-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - font-size: 11px; - color: var(--fg-dim); - cursor: pointer; -} - -.term-theme-color-row input[type="color"] { - width: 28px; - height: 28px; - border: 1px solid var(--border); - border-radius: 50%; - padding: 0; - cursor: pointer; - background: none; -} - -/* Terminal maximize button (next to the palette one) */ -.term-max-btn { - bottom: 8px; - right: 44px; -} - -.term-add-btn { - bottom: 8px; - right: 80px; - font-size: 18px; - font-weight: 300; - line-height: 1; -} - -/* Profiles section in the popover */ -.term-profiles-section { - border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); - padding-top: 10px; - display: flex; - flex-direction: column; - gap: 6px; -} - -.term-profile-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.term-profile-row { - display: flex; - align-items: center; - gap: 4px; -} - -.term-profile-name { - flex: 1; - text-align: left; - background: var(--surface-2); - border: 1px solid var(--border); - border-radius: 4px; - color: var(--fg); - font-size: 11px; - padding: 3px 8px; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.term-profile-name:hover { border-color: var(--accent); color: var(--accent); } - -.term-profile-del { - background: transparent; - border: none; - color: var(--fg-dim); - cursor: pointer; - font-size: 14px; - padding: 0 4px; - line-height: 1; - flex-shrink: 0; -} - -.term-profile-del:hover { color: var(--fg); } - -.term-profile-save { - background: transparent; - border: 1px dashed var(--border); - border-radius: 4px; - color: var(--fg-dim); - font-size: 11px; - padding: 4px 8px; - cursor: pointer; - text-align: left; - transition: color 0.1s, border-color 0.1s; -} - -.term-profile-save:hover { color: var(--accent); border-color: var(--accent); } /* Context menu */ @@ -2236,32 +2076,6 @@ html, body, #app { pointer-events: auto; } -.agents-term-area.agents-split-h { display: flex; flex-direction: row; } -.agents-term-area.agents-split-v { display: flex; flex-direction: column; } - -.agents-term-area.agents-split-h .agents-term-slot.split-member, -.agents-term-area.agents-split-v .agents-term-slot.split-member { - position: relative; - inset: auto; - flex: 1; - min-width: 0; - min-height: 0; - visibility: visible; - pointer-events: auto; -} - -.agents-term-area.agents-split-h .agents-term-slot.split-member + .agents-term-slot.split-member { - border-left: 1px solid var(--border); -} -.agents-term-area.agents-split-v .agents-term-slot.split-member + .agents-term-slot.split-member { - border-top: 1px solid var(--border); -} - -/* Split group indicator in sidebar */ -.agents-sidebar-item.agents-sidebar-group-member { - border-left: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); - padding-left: 6px; -} .agents-term-slot .terminal-panel { height: 100%; @@ -2319,14 +2133,6 @@ html, body, #app { } -/* Terminal theme button: only when hovering the panel */ -.term-theme-btn { - opacity: 0; -} - -.terminal-panel:hover .term-theme-btn { - opacity: 1; -} /* ===== Workspace empty state (no panels open) ===== */ .workspace-empty { From 0973481a7e4016685edf6c196dee8a2625b0a75d Mon Sep 17 00:00:00 2001 From: romadesign Date: Wed, 12 Aug 2026 10:06:57 +0200 Subject: [PATCH 25/25] fix: force-hide dockview tab bar in all cases --- src/panels/tasks/TasksPanelRuntime.ts | 5 +++++ src/panels/terminal/TerminalPanel.ts | 2 +- src/styles.css | 5 +---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/panels/tasks/TasksPanelRuntime.ts b/src/panels/tasks/TasksPanelRuntime.ts index 3528e2c..1d8790f 100644 --- a/src/panels/tasks/TasksPanelRuntime.ts +++ b/src/panels/tasks/TasksPanelRuntime.ts @@ -896,6 +896,11 @@ export function createTasksPanel(panelId = 'default'): { element: HTMLElement; d }) const menuBtn = iconBtn('more', taskT('actions'), () => { + // iconBtn stops propagation, so opening the row menu must explicitly + // count as user interaction. Otherwise a slow startup enrichment can + // still "restore" the saved task after an action (for example Backups) + // has navigated elsewhere and replace that newly opened detail. + selectWorktree(row, wt) const r = menuBtn.getBoundingClientRect() showContextMenu(r.right - 4, r.bottom, menuItems()) }) diff --git a/src/panels/terminal/TerminalPanel.ts b/src/panels/terminal/TerminalPanel.ts index c557a38..de74cfb 100644 --- a/src/panels/terminal/TerminalPanel.ts +++ b/src/panels/terminal/TerminalPanel.ts @@ -59,7 +59,7 @@ export function createTerminalPanel(panelId = '', projectPath = '', onExit?: () const root = document.createElement('div') root.className = 'terminal-panel' - let localFontFamily = DEFAULT_FONT_FAMILY + const localFontFamily = DEFAULT_FONT_FAMILY const term = new Terminal({ cursorBlink: true, diff --git a/src/styles.css b/src/styles.css index 62c0475..ac2111b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -487,10 +487,7 @@ html, body, #app { /* Launcher-driven navigation: the panel tab bar is hidden entirely so panels fill to the very top. Open/switch from the side launcher; close via right-click. */ .workspace-view .dv-groupview > .dv-tabs-and-actions-container { - display: none; -} -.workspace-view .dv-groupview:has(.dv-tab ~ .dv-tab) > .dv-tabs-and-actions-container { - display: flex; + display: none !important; } .panel-placeholder {