From c4fcd1cfd5a598f38b343fcf21db3c3e65c8fe45 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Sun, 12 Apr 2026 19:20:37 -0700 Subject: [PATCH 01/14] sessions: add mobile-compatible PWA layout for agent sessions Introduce a responsive mobile layout for the agent sessions window, enabling a native app-like experience when accessed via mobile browsers or installed as a PWA. The implementation uses mobile Part subclasses, a MobileTopBar chrome component, CSS overrides, and when-clause gating to progressively enhance the desktop sessions workbench for phone viewports. Key changes: Layout policy & viewport detection: - SessionsLayoutPolicy with observable viewport classification (phone <640px, tablet 640-1024, desktop >1024) - Context keys: ViewportClassContext, IsMobileLayoutContext, KeyboardVisibleContext - Runtime viewport class change detection in layout() Mobile chrome components: - MobileTopBar: hamburger + session title + new session (+) - MobileNavigationStack: history.pushState integration for Android back button - Sidebar drawer overlay with backdrop dismiss Mobile Part subclasses: - MobileChatBarPart, MobileSidebarPart, MobileAuxiliaryBarPart, MobilePanelPart override layout()/updateStyles() only - AgenticPaneCompositePartService conditionally instantiates mobile vs desktop Parts View gating: - Desktop-only views hidden on mobile via IsMobileLayoutContext: Changes, Files, Logs, Terminal, Code Review, Open in VS Code - Customization toolbar hidden via CSS on phone CSS & PWA: - Edge-to-edge chat (no card chrome on phone) - Safe area insets, touch targets (44px min), overscroll containment - PWA manifest with standalone display mode - viewport-fit=cover meta tag - Dynamic theme-color meta created programmatically Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- resources/server/manifest.json | 3 + src/vs/code/browser/workbench/workbench.html | 2 +- src/vs/sessions/MOBILE.md | 157 ++++++++ src/vs/sessions/browser/layoutPolicy.ts | 152 ++++++++ src/vs/sessions/browser/media/style.css | 252 +++++++++++++ .../sessions/browser/mobileNavigationStack.ts | 108 ++++++ .../browser/paneCompositePartService.ts | 17 +- src/vs/sessions/browser/parts/chatBarPart.ts | 2 +- .../browser/parts/media/sidebarPart.css | 54 +++ .../browser/parts/media/titlebarpart.css | 34 ++ .../parts/mobile/mobileAuxiliaryBarPart.ts | 40 ++ .../browser/parts/mobile/mobileChatBarPart.ts | 43 +++ .../browser/parts/mobile/mobileChatShell.css | 354 ++++++++++++++++++ .../browser/parts/mobile/mobilePanelPart.ts | 40 ++ .../browser/parts/mobile/mobileSidebarPart.ts | 28 ++ .../browser/parts/mobile/mobileTopBar.ts | 67 ++++ src/vs/sessions/browser/workbench.ts | 352 +++++++++++++++-- src/vs/sessions/common/contextkeys.ts | 8 + .../changes/browser/changes.contribution.ts | 2 + .../chat/browser/openInVSCode.contribution.ts | 4 +- .../openInVSCode.contribution.ts | 4 +- .../browser/codeReview.contributions.ts | 2 + .../files/browser/files.contribution.ts | 5 +- .../contrib/logs/browser/logs.contribution.ts | 2 + .../sessions/browser/media/sessionsList.css | 20 + .../browser/sessionsTerminalContribution.ts | 4 +- 26 files changed, 1711 insertions(+), 45 deletions(-) create mode 100644 src/vs/sessions/MOBILE.md create mode 100644 src/vs/sessions/browser/layoutPolicy.ts create mode 100644 src/vs/sessions/browser/mobileNavigationStack.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileChatShell.css create mode 100644 src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts create mode 100644 src/vs/sessions/browser/parts/mobile/mobileTopBar.ts diff --git a/resources/server/manifest.json b/resources/server/manifest.json index 443e3fd1cb1fd..71a66a8173bcb 100644 --- a/resources/server/manifest.json +++ b/resources/server/manifest.json @@ -5,6 +5,9 @@ "lang": "en-US", "display": "standalone", "display_override": ["window-controls-overlay"], + "background_color": "#1e1e1e", + "theme_color": "#1e1e1e", + "orientation": "any", "icons": [ { "src": "code-192.png", diff --git a/src/vs/code/browser/workbench/workbench.html b/src/vs/code/browser/workbench/workbench.html index 7788198273570..9401fd6cae924 100644 --- a/src/vs/code/browser/workbench/workbench.html +++ b/src/vs/code/browser/workbench/workbench.html @@ -14,7 +14,7 @@ - + diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md new file mode 100644 index 0000000000000..aff0afde7d27c --- /dev/null +++ b/src/vs/sessions/MOBILE.md @@ -0,0 +1,157 @@ +# Mobile Agent Sessions — Architecture + +## Core Principle + +**Every feature accessible in the desktop window must be accessible on mobile — same functionality, different presentation.** Mobile is NOT "desktop minus stuff." It is a parallel UI layer where the same services, views, and actions are rendered through mobile-native interaction patterns. + +## Architecture + +### Mobile Part Subclasses + +Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) remain unchanged. Each has a **mobile subclass** that extends it and overrides only `layout()` and/or `updateStyles()` to remove card margins, border insets, and inline theme styles. `AgenticPaneCompositePartService` conditionally instantiates the mobile or desktop variant at startup based on viewport width (`< 640px` → phone). + +This means: +- Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTopBar`, and CSS. + +**Known limitation:** Part classes are chosen once at construction and never swapped at runtime. If the viewport changes class (e.g., device rotation from portrait to landscape), the original Part implementations remain. This is acceptable because real mobile devices don't switch between phone and desktop — the scenario only occurs in DevTools emulation. + +### View & Action Gating + +Views, menu items, and actions use `when` clauses with the `sessionsIsMobileLayout` context key to control visibility per viewport class. This follows a **default-deny** approach for mobile: + +- **Desktop-only features** add `when: IsMobileLayoutContext.negate()` to their view descriptors and menu registrations. They simply don't appear on mobile. +- **Mobile-compatible features** (chat, sessions list) have no mobile gate — they render on all viewports. +- **Mobile-specific replacements** (when ready) register with `when: IsMobileLayoutContext` and live in separate files under `parts/mobile/contributions/`. + +Two registrations can target the same slot with opposite `when` clauses, pointing to different view classes in different files — giving full file separation with no internal branching. + +#### Current Gating Status + +| Feature | Mobile Status | Mechanism | +|---------|--------------|-----------| +| Sessions list (sidebar) | ✅ Compatible | No gate | +| Chat views (ChatBar) | ✅ Compatible | No gate | +| Changes view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsMobileLayout` on view descriptor | +| Files view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsMobileLayout` on view descriptor | +| Logs view (Panel) | ❌ Gated | `when: !sessionsIsMobileLayout` on view descriptor | +| Terminal actions | ❌ Gated | `when: !sessionsIsMobileLayout` on menu item | +| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsMobileLayout` on menu item | +| Code review toolbar | ❌ Gated | `when: !sessionsIsMobileLayout` on menu item | +| Customizations toolbar | ❌ Hidden | CSS `display: none` on phone | +| Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTopBar replacement | + +### Phone Layout + +On phone-sized viewports (`< 640px` width): + +``` +┌──────────────────────────────────┐ +│ [☰] Session Title [+] │ ← MobileTopBar (prepended before grid) +├──────────────────────────────────┤ +│ │ +│ Chat (edge-to-edge) │ ← Grid: ChatBarPart fills 100% +│ │ +│ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ Chat input │ │ ← Pinned to bottom +│ └──────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +- **MobileTopBar** is a DOM element prepended above the grid. It has a hamburger (☰), session title, and new session (+) button. +- **Sidebar** is hidden by default and opens as an **85% width drawer overlay** with a backdrop when the hamburger is tapped. CSS makes its `split-view-view` absolutely positioned with `z-index: 250`. The workbench manually calls `sidebarPart.layout()` with drawer dimensions after opening. Closing the drawer clears the navigation stack. +- **Titlebar** is hidden in the grid (`visible: false`) and via CSS — replaced by MobileTopBar. +- **SessionCompositeBar** (chat tabs) is hidden via CSS. +- The grid uses `display: flex; flex-direction: column` and all `split-view-view:has(> .part)` containers are positioned absolutely at `100% width/height`. + +### Viewport Classification + +`SessionsLayoutPolicy` classifies the viewport: +- **phone**: `width < 640px` +- **tablet**: `640px ≤ width < 1024px` +- **desktop**: `width ≥ 1024px` + +The workbench toggles CSS classes (`phone-layout`, `mobile-layout`) on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation). MobileTopBar lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. + +### Context Keys + +| Key | Type | Purpose | +|-----|------|---------| +| `sessionsViewportClass` | `string` | `'phone'`, `'tablet'`, or `'desktop'` | +| `sessionsIsMobileLayout` | `boolean` | `true` when phone or tablet | +| `sessionsKeyboardVisible` | `boolean` | `true` when virtual keyboard is visible | + +### Desktop → Mobile Component Mapping + +| Desktop Component | Mobile Equivalent | How Accessed | +|---|---|---| +| **Titlebar** (3-section toolbar) | **MobileTopBar** (☰ / title / +) | Always visible at top | +| **Sidebar** (sessions list) | Drawer overlay (85% width) | Hamburger button (☰) | +| **ChatBar** (chat widget) | Same Part, edge-to-edge, no card chrome | Default view (always visible) | +| **AuxiliaryBar** (files, changes) | Gated — not shown on mobile | Planned: mobile-specific view | +| **Panel** (terminal, output) | Gated — not shown on mobile | Planned: mobile-specific view | +| **SessionCompositeBar** (chat tabs) | Hidden on phone | — | +| **New Session** (sidebar button) | + button in MobileTopBar | Always visible in top bar | + +## File Map + +### Mobile Part Subclasses + +| File | Purpose | +|------|---------| +| `src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts` | Extends `ChatBarPart`. Overrides `layout()` (no card margins) and `updateStyles()` (no inline card styles). | +| `src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts` | Extends `SidebarPart`. Overrides `updateStyles()` (no inline card/title styles). | +| `src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts` | Extends `AuxiliaryBarPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | +| `src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts` | Extends `PanelPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | + +### Mobile Chrome Components + +| File | Purpose | +|------|---------| +| `src/vs/sessions/browser/parts/mobile/mobileTopBar.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | +| `src/vs/sessions/browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | + +### Layout & Navigation + +| File | Purpose | +|------|---------| +| `src/vs/sessions/browser/layoutPolicy.ts` | `SessionsLayoutPolicy`: observable viewport classification (phone/tablet/desktop), platform flags (isIOS, isAndroid, isTouchDevice), part visibility and size defaults. | +| `src/vs/sessions/browser/mobileNavigationStack.ts` | `MobileNavigationStack`: Android back button integration via `history.pushState` / `popstate`. Supports `push()`, `pop()`, and `clear()`. | +| `src/vs/sessions/common/contextkeys.ts` | Mobile context keys: `ViewportClassContext`, `IsMobileLayoutContext`, `KeyboardVisibleContext`. | + +### Part Instantiation + +| File | Purpose | +|------|---------| +| `src/vs/sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService`: checks viewport width at construction time and instantiates `Mobile*Part` vs desktop `*Part` classes accordingly. | + +### Workbench Integration + +| File | Key Changes | +|------|-------------| +| `src/vs/sessions/browser/workbench.ts` | Layout policy integration, MobileTopBar creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTopBar height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | +| `src/vs/sessions/browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. | + +### Styling + +| File | Purpose | +|------|---------| +| `src/vs/sessions/browser/parts/mobile/mobileChatShell.css` | All phone-layout CSS (see above). | +| `src/vs/sessions/browser/parts/media/sidebarPart.css` | Sidebar drawer overlay CSS: 85% width, z-index 250, slide-in animation, backdrop. | +| `src/vs/sessions/browser/media/style.css` | Mobile overscroll containment, 44px touch targets, quick pick bottom sheets, context menu action sheets, dialog sizing, notification positioning, hover card suppression, editor modal full-screen. | + +### PWA & Viewport + +| File | Purpose | +|------|---------| +| `src/vs/code/browser/workbench/workbench.html` | `viewport-fit=cover` meta tag, `theme-color` meta tag. | +| `resources/server/manifest.json` | PWA manifest: `background_color`, `theme_color`, `orientation`. | + +## Remaining Work + +- **Session title sync**: MobileTopBar shows hardcoded "New Session" — needs to subscribe to `sessionsManagementService.activeSession` and update title when session changes. +- **Files & Terminal access**: Should become mobile-specific views gated with `when: IsMobileLayoutContext`. +- **iOS keyboard handling**: Adjust layout when virtual keyboard appears (context key exists, but no layout response yet). +- **Session list inline actions**: Make always-visible on touch devices (no hover-to-reveal). +- **Customizations on mobile**: Currently hidden — needs a mobile-friendly alternative. diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts new file mode 100644 index 0000000000000..95da097c2f857 --- /dev/null +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../base/common/lifecycle.js'; +import { observableValue, derived, IObservable } from '../../base/common/observable.js'; +import { isIOS } from '../../base/common/platform.js'; +import { isAndroid } from '../../base/browser/browser.js'; +import { Gesture } from '../../base/browser/touch.js'; + +/** Viewport classification based on container width. */ +export type ViewportClass = 'phone' | 'tablet' | 'desktop'; + +/** Default visibility for each workbench part. */ +export interface IPartVisibilityDefaults { + readonly sidebar: boolean; + readonly auxiliaryBar: boolean; + readonly panel: boolean; + readonly chatBar: boolean; + readonly editor: boolean; +} + +/** Default sizes (in pixels) for each workbench part. */ +export interface IPartSizeDefaults { + readonly sideBarSize: number; + readonly auxiliaryBarSize: number; + readonly panelSize: number; + readonly chatBarWidth: number; +} + +const PHONE_MAX_WIDTH = 640; +const TABLET_MAX_WIDTH = 1024; + +/** + * Classifies the viewport into one of three classes based on width. + */ +function classifyViewport(width: number): ViewportClass { + if (width < PHONE_MAX_WIDTH) { + return 'phone'; + } + if (width < TABLET_MAX_WIDTH) { + return 'tablet'; + } + return 'desktop'; +} + +/** + * Observable-based viewport classification and layout policy for + * the Sessions workbench. Consumed by `SessionsWorkbench` to drive + * part visibility, sizing, and behavior based on viewport dimensions + * and platform. + */ +export class SessionsLayoutPolicy extends Disposable { + + // --- Platform flags (static, read once) --- + + /** Whether the current platform is iOS. */ + readonly isIOS: boolean; + + /** Whether the current platform is Android. */ + readonly isAndroid: boolean; + + /** Whether the current device supports touch input. */ + readonly isTouchDevice: boolean; + + // --- Observables --- + + private readonly _viewportClass = observableValue(this, 'desktop'); + + /** Current viewport class derived from the most recent `update()` call. */ + readonly viewportClass: IObservable = this._viewportClass; + + /** `true` when the viewport class is `phone` or `tablet`. */ + readonly isMobileLayout: IObservable = derived(this, reader => { + const vc = this._viewportClass.read(reader); + return vc === 'phone' || vc === 'tablet'; + }); + + constructor() { + super(); + + this.isIOS = isIOS; + this.isAndroid = isAndroid; + this.isTouchDevice = Gesture.isTouchDevice(); + } + + /** + * Update the viewport classification. Call this from the workbench + * `layout()` method whenever the container dimensions change. + * + * @param width Container width in pixels. + * @param height Container height in pixels (reserved for future use). + */ + update(width: number, _height: number): void { + const next = classifyViewport(width); + if (this._viewportClass.get() !== next) { + this._viewportClass.set(next, undefined); + } + } + + /** + * Returns the default part visibility for the given viewport class. + * If no class is supplied the current observed class is used. + */ + getPartVisibilityDefaults(viewportClass?: ViewportClass): IPartVisibilityDefaults { + const vc = viewportClass ?? this._viewportClass.get(); + switch (vc) { + case 'phone': + return { sidebar: false, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; + case 'tablet': + return { sidebar: true, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; + case 'desktop': + return { sidebar: true, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; + } + } + + /** + * Returns the default part sizes for the given viewport dimensions. + * If no viewport class is supplied the current observed class is used. + * + * @param width Container width in pixels. + * @param height Container height in pixels (reserved for future use). + * @param viewportClass Optional explicit viewport class override. + */ + getPartSizes(width: number, _height: number, viewportClass?: ViewportClass): IPartSizeDefaults { + const vc = viewportClass ?? this._viewportClass.get(); + switch (vc) { + case 'phone': + return { + sideBarSize: 0, + auxiliaryBarSize: 0, + panelSize: 0, + chatBarWidth: width, + }; + case 'tablet': + return { + sideBarSize: 250, + auxiliaryBarSize: 300, + panelSize: 250, + chatBarWidth: width - 250, + }; + case 'desktop': + return { + sideBarSize: 300, + auxiliaryBarSize: 380, + panelSize: 300, + chatBarWidth: width - 300, + }; + } + } +} diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 22f41207b4f89..483f64e138a50 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -647,3 +647,255 @@ .agent-sessions-workbench .badge > .badge-content { border-radius: 4px !important; } + +/* Phone-layout rules for parts, sashes, max-width constraints, and grid + background live in mobileChatShell.css — do not duplicate them here. */ + +/* ---- Mobile Layout: Overscroll Containment ---- */ + +/* Prevent body rubber-band on iOS and Chrome pull-to-refresh on Android */ +.agent-sessions-workbench.mobile-layout .monaco-scrollable-element > .scrollable-element { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.mobile-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.mobile-layout .monaco-list { + overscroll-behavior: contain; +} + +/* ---- Mobile Layout: Touch Target Sizing ---- */ + +/* Ensure interactive elements meet 44px minimum touch target */ +.agent-sessions-workbench.mobile-layout .action-item > .action-label { + min-height: 44px; + min-width: 44px; +} + +/* Touch action for tap responsiveness */ +.agent-sessions-workbench.mobile-layout .action-item, +.agent-sessions-workbench.mobile-layout button { + touch-action: manipulation; +} + +/* Disable text selection callout on interactive elements */ +.agent-sessions-workbench.mobile-layout .action-item, +.agent-sessions-workbench.mobile-layout .monaco-toolbar, +.agent-sessions-workbench.mobile-layout .sidebar-footer { + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; +} + +/* Titlebar safe-area inset lives in mobileChatShell.css */ + +/* ---- Phone Layout: Mobile Quick Picks ---- */ + +/* Transform quick pick into full-width bottom sheet on phone */ +.agent-sessions-workbench.phone-layout .quick-input-widget { + top: auto !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + max-width: 100% !important; + border-radius: 16px 16px 0 0 !important; + padding-bottom: env(safe-area-inset-bottom); +} + +.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list { + max-height: 50vh !important; +} + +.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list .monaco-list-row { + min-height: 44px; +} + +/* ---- Phone Layout: Mobile Context Menus ---- */ + +/* Transform context menus into bottom action sheets on phone */ +.agent-sessions-workbench.phone-layout .context-view .monaco-menu { + position: fixed !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + top: auto !important; + width: 100% !important; + max-width: 100% !important; + border-radius: 16px 16px 0 0; + padding-bottom: env(safe-area-inset-bottom); +} + +.agent-sessions-workbench.phone-layout .context-view .monaco-menu .monaco-action-bar .action-item { + min-height: 44px; +} + +.agent-sessions-workbench.phone-layout .context-view .monaco-menu .monaco-action-bar .action-label { + font-size: 16px; + padding: 8px 16px; +} + +/* ---- Phone Layout: Mobile Dialogs ---- */ + +/* Make dialogs near-full-width with larger buttons on phone */ +.agent-sessions-workbench.phone-layout .monaco-dialog-box { + width: calc(100% - 32px) !important; + max-width: calc(100% - 32px) !important; +} + +.agent-sessions-workbench.phone-layout .monaco-dialog-box .dialog-buttons-row .monaco-button { + min-height: 44px; + font-size: 16px; +} + +/* ---- Phone Layout: Mobile Notifications ---- */ + +/* Full-width notification toasts at top of screen */ +.agent-sessions-workbench.phone-layout .notifications-toasts { + left: 8px !important; + right: 8px !important; + bottom: auto !important; + top: calc(env(safe-area-inset-top) + 48px) !important; + width: auto !important; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast { + width: 100% !important; + max-width: 100% !important; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { + border-radius: 12px; +} + +/* ---- Mobile Layout: Hover Cards ---- */ + +/* Disable delayed hover cards on touch devices — they never trigger */ +.agent-sessions-workbench.mobile-layout .monaco-hover { + display: none !important; +} + +/* Exception: keep hovers that are explicitly triggered (e.g., info buttons) */ +.agent-sessions-workbench.mobile-layout .monaco-hover.visible-on-mobile { + display: block !important; +} + +/* ---- Phone Layout: Mobile Editor Modal ---- */ + +/* Full-screen editor modal on phone — no margins, covers entire viewport */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-part { + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; + border-radius: 0 !important; + margin: 0 !important; + animation: editor-slide-up 250ms ease-out; +} + +@keyframes editor-slide-up { + from { + transform: translateY(30%); + opacity: 0.5; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Make the modal backdrop fully cover the screen on phone */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-block { + background: rgba(0, 0, 0, 0.7); +} + +/* Safe area padding for editor titlebar on phone */ +.agent-sessions-workbench.phone-layout .monaco-modal-editor-part .title { + padding-top: env(safe-area-inset-top); +} + +/* ---- Mobile Layout: Input Auto-Zoom Prevention ---- */ + +/* iOS Safari zooms in on input focus when font-size < 16px. + Force minimum 16px on all input elements in mobile layout. */ +.agent-sessions-workbench.mobile-layout input, +.agent-sessions-workbench.mobile-layout textarea, +.agent-sessions-workbench.mobile-layout .monaco-inputbox input, +.agent-sessions-workbench.mobile-layout .chat-input-container textarea { + font-size: 16px !important; +} + +/* ---- Mobile Layout: Native Scroll Preservation ---- */ + +/* Ensure chat content uses momentum scrolling on mobile. + The -webkit-overflow-scrolling property is needed for older iOS. */ +.agent-sessions-workbench.mobile-layout .interactive-session .monaco-scrollable-element { + -webkit-overflow-scrolling: touch; +} + +/* ---- Phone Layout: Bottom Sheet Panel ---- */ + +/* Panel slides up from bottom as a sheet on phone */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.panel) { + position: absolute !important; + bottom: 0 !important; + left: 0 !important; + right: 0 !important; + height: 60vh !important; + z-index: 200; + animation: panel-slide-up 250ms ease-out; + border-radius: 16px 16px 0 0; + overflow: hidden; +} + +@keyframes panel-slide-up { + from { + transform: translateY(100%); + opacity: 0.5; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Panel drag handle visual indicator */ +.agent-sessions-workbench.phone-layout .part.panel::before { + content: ''; + display: block; + width: 36px; + height: 5px; + background: var(--vscode-foreground); + opacity: 0.3; + border-radius: 3px; + margin: 8px auto 4px auto; +} + +/* ---- Phone Layout: Auxiliary Bar Overlay ---- */ + +/* Auxiliary bar slides in from the right as a full-height overlay on phone */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.auxiliarybar) { + position: absolute !important; + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 85vw !important; + max-width: 400px; + z-index: 200; + animation: auxbar-slide-in 250ms ease-out; +} + +@keyframes auxbar-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} diff --git a/src/vs/sessions/browser/mobileNavigationStack.ts b/src/vs/sessions/browser/mobileNavigationStack.ts new file mode 100644 index 0000000000000..f03f5ae55cc08 --- /dev/null +++ b/src/vs/sessions/browser/mobileNavigationStack.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../base/common/event.js'; +import { mainWindow } from '../../base/browser/window.js'; + +export type MobileNavigationLayer = 'sidebar' | 'editor' | 'panel' | 'auxbar'; + +interface MobileNavigationEntry { + readonly layer: MobileNavigationLayer; + readonly id: number; +} + +/** + * Manages a stack of open overlay layers (sidebar, editor modal, panel sheet, + * aux bar) and integrates with `history.pushState` / `popstate` so that the + * Android back button dismisses overlays in LIFO order. + */ +export class MobileNavigationStack extends Disposable { + + private readonly _stack: MobileNavigationEntry[] = []; + private _nextId = 0; + + private readonly _onDidPop = this._register(new Emitter()); + readonly onDidPop: Event = this._onDidPop.event; + + constructor() { + super(); + + this._register(Event.fromDOMEventEmitter(mainWindow, 'popstate')(e => { + this._onPopState(e); + })); + } + + push(layer: MobileNavigationLayer): void { + const id = this._nextId++; + this._stack.push({ layer, id }); + mainWindow.history.pushState({ layer, id }, ''); + } + + pop(): MobileNavigationLayer | undefined { + const entry = this._stack.pop(); + if (entry) { + this._onDidPop.fire(entry.layer); + } + return entry?.layer; + } + + peek(): MobileNavigationLayer | undefined { + return this._stack.length > 0 + ? this._stack[this._stack.length - 1].layer + : undefined; + } + + has(layer: MobileNavigationLayer): boolean { + return this._stack.some(e => e.layer === layer); + } + + clear(): void { + this._stack.length = 0; + } + + /** + * Removes the topmost entry matching `layer` from the stack (without + * firing {@link onDidPop}) and rewinds the browser history by one entry. + * Use this when a layer is closed by UI interaction (e.g., backdrop click) + * so the history and stack stay in sync without recursing back into + * close handlers. + */ + popSilently(layer: MobileNavigationLayer): void { + for (let i = this._stack.length - 1; i >= 0; i--) { + if (this._stack[i].layer === layer) { + this._stack.splice(i, 1); + this._suppressNextPop = true; + mainWindow.history.back(); + return; + } + } + } + + private _suppressNextPop = false; + + private _onPopState(e: PopStateEvent): void { + if (this._suppressNextPop) { + this._suppressNextPop = false; + return; + } + + if (this._stack.length === 0) { + return; + } + + const top = this._stack[this._stack.length - 1]; + const state = e.state as { layer?: string; id?: number } | null; + + // Only pop if the event's state id matches expectations — + // the popstate must correspond to a state *before* our top entry, + // meaning the top entry's push was just undone. + if (state && typeof state.id === 'number' && state.id >= top.id) { + return; + } + + this.pop(); + } +} diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts index 060cdfdedade6..1c41eb907db18 100644 --- a/src/vs/sessions/browser/paneCompositePartService.ts +++ b/src/vs/sessions/browser/paneCompositePartService.ts @@ -18,6 +18,12 @@ import { PanelPart } from './parts/panelPart.js'; import { SidebarPart } from './parts/sidebarPart.js'; import { AuxiliaryBarPart } from './parts/auxiliaryBarPart.js'; import { ChatBarPart } from './parts/chatBarPart.js'; +import { MobilePanelPart } from './parts/mobile/mobilePanelPart.js'; +import { MobileSidebarPart } from './parts/mobile/mobileSidebarPart.js'; +import { MobileAuxiliaryBarPart } from './parts/mobile/mobileAuxiliaryBarPart.js'; +import { MobileChatBarPart } from './parts/mobile/mobileChatBarPart.js'; +import { getClientArea } from '../../base/browser/dom.js'; +import { mainWindow } from '../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { @@ -37,10 +43,13 @@ export class AgenticPaneCompositePartService extends Disposable implements IPane ) { super(); - this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); - this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); - this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); - this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); + const { width } = getClientArea(mainWindow.document.body); + const isPhoneLayout = width < 640; + + this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(isPhoneLayout ? MobilePanelPart : PanelPart)); + this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(isPhoneLayout ? MobileSidebarPart : SidebarPart)); + this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(isPhoneLayout ? MobileAuxiliaryBarPart : AuxiliaryBarPart)); + this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(isPhoneLayout ? MobileChatBarPart : ChatBarPart)); } private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index 29c7a30c249d2..273b79fce2b46 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -58,7 +58,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not private _sessionCompositeBar: ChatCompositeBar | undefined; - private _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; + protected _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; get preferredHeight(): number | undefined { return this.layoutService.mainContainerDimension.height * 0.4; diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index a30fd1d55f9dd..2774335cf422c 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -67,3 +67,57 @@ max-width: 100%; cursor: default; } + +/* ---- Phone Layout: Sidebar Drawer Overlay ---- */ + +/* On phone, the sidebar is a drawer that slides over the chat. + It takes 85% width (max 360px) and sits on top of everything. */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part.sidebar) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + width: 85% !important; + max-width: 360px !important; + height: 100% !important; + z-index: 250; + animation: sidebar-slide-in 200ms ease-out; +} + +/* The sidebar Part inside fills its container */ +.agent-sessions-workbench.phone-layout .part.sidebar { + width: 100% !important; + height: 100% !important; +} + +@keyframes sidebar-slide-in { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +/* Sidebar backdrop — applied via JS when sidebar is open on phone */ +.mobile-sidebar-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 240; + animation: backdrop-fade-in 200ms ease-out; +} + +@keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Increase sidebar footer action button height for touch */ +.agent-sessions-workbench.mobile-layout .part.sidebar > .sidebar-footer .sidebar-action-button { + min-height: 44px; + padding: 8px 12px; +} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index db6da32ee33c2..b17ad7d23d265 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -130,3 +130,37 @@ .agent-sessions-workbench.mac .part.titlebar .window-controls-container { -webkit-app-region: drag; } + +/* ---- Phone Layout: Minimal Titlebar ---- */ + +/* On phone, ensure the titlebar left is visible (it holds the hamburger area) + even when sidebar is hidden. Override the nosidebar rule. */ +.agent-sessions-workbench.phone-layout.nosidebar .part.titlebar > .sessions-titlebar-container > .titlebar-left { + display: flex !important; +} + +/* But hide the toolbar content inside it — only structural element remains */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { + display: none !important; +} + +/* Hide the window controls spacer on phone (no native traffic lights on mobile) */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-left > .window-controls-container { + display: none !important; +} + +/* Keep the center (session title) visible and full-width on phone */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-center { + flex: 1; + min-width: 0; +} + +/* On phone, hide ALL right-side action containers (session actions + layout actions) */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container { + display: none !important; +} + +/* Ensure safe area padding on top for notch */ +.agent-sessions-workbench.phone-layout .part.titlebar > .sessions-titlebar-container { + padding-top: env(safe-area-inset-top); +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts new file mode 100644 index 0000000000000..37118ab8d2d2b --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { Part } from '../../../../workbench/browser/part.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { AuxiliaryBarPart } from '../auxiliaryBarPart.js'; + +/** + * Mobile variant of AuxiliaryBarPart. + * + * On phone-sized viewports the auxiliary bar fills the full grid cell + * without card margins or border insets. + */ +export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { + + override updateStyles(): void { + // Run base theme wiring (skips AuxiliaryBarPart's card-specific inline styles) + AbstractPaneCompositePart.prototype.updateStyles.call(this); + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + return; + } + + // Full dimensions — no card margins or border subtraction + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + Part.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts new file mode 100644 index 0000000000000..e78877a0c4110 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { Part } from '../../../../workbench/browser/part.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { ChatBarPart } from '../chatBarPart.js'; + +/** + * Mobile variant of ChatBarPart. + * + * On phone-sized viewports the chat bar fills the full grid cell without + * card margins, border insets, or session-bar height adjustments. + */ +export class MobileChatBarPart extends ChatBarPart { + + override updateStyles(): void { + // Run base theme wiring (skips ChatBarPart's card-specific inline styles) + AbstractPaneCompositePart.prototype.updateStyles.call(this); + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + container.style.color = ''; + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.CHATBAR_PART)) { + return; + } + + this._lastLayout = { width, height, top, left }; + + // Full dimensions — no card margins or session-bar subtraction + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + Part.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css new file mode 100644 index 0000000000000..dfcbc6f02d0f7 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -0,0 +1,354 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Mobile Top Bar ---- */ + +.mobile-top-bar { + display: flex; + align-items: center; + height: 48px; + min-height: 48px; + padding: 0 4px; + padding-top: env(safe-area-inset-top); + background: var(--vscode-editor-background); + flex-shrink: 0; + -webkit-touch-callout: none; + user-select: none; + -webkit-user-select: none; + touch-action: manipulation; + z-index: 10; +} + +.mobile-top-bar .mobile-top-bar-button { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: none; + background: none; + color: var(--vscode-foreground); + cursor: pointer; + border-radius: 50%; + flex-shrink: 0; + touch-action: manipulation; + font-size: 18px; + padding: 0; +} + +.monaco-workbench .mobile-top-bar .mobile-top-bar-button:focus { + outline: none !important; +} + +.mobile-top-bar .mobile-top-bar-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.mobile-top-bar .mobile-top-bar-button:active { + background: var(--vscode-toolbar-hoverBackground); +} + +.mobile-top-bar .mobile-session-title { + flex: 1; + min-width: 0; + text-align: center; + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 4px; + cursor: pointer; +} + +.mobile-top-bar .mobile-session-title:active { + opacity: 0.7; +} + +/* ---- Phone Layout: Full-screen chat ---- */ + +/* On phone, stack the mobile top bar and grid vertically */ +.agent-sessions-workbench.phone-layout { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +/* On phone, split-view-views that directly contain a Part fill the full + grid area. Uses :has(> .part) to target only part containers — NOT + nested split-views inside parts' own content. */ +.agent-sessions-workbench.phone-layout .split-view-view:has(> .part) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* The grid's own branch nodes (NOT those inside parts) need full sizing. + Target only direct children of the grid root. */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Split-view-views inside the grid root that contain branch nodes */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view:has(> .monaco-grid-branch-node) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Second-level grid branch nodes */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Third-level (top-right section) */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view:has(> .monaco-grid-branch-node) { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +.agent-sessions-workbench.phone-layout > .monaco-grid-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node > .monaco-split-view2 > .split-view-container > .split-view-view > .monaco-grid-branch-node { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; +} + +/* Remove card appearance from ALL parts on phone */ +.agent-sessions-workbench.phone-layout .part.chatbar, +.agent-sessions-workbench.phone-layout .part.sidebar, +.agent-sessions-workbench.phone-layout .part.auxiliarybar, +.agent-sessions-workbench.phone-layout .part.panel { + margin: 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + --part-border-color: transparent !important; + width: 100% !important; + height: 100% !important; +} + +/* Force content div inside parts to fill the part on phone. + Part.layoutContents() sets inline width/height via size(), which + may use the grid-allocated dimensions rather than the CSS-overridden + 100% dimensions. Override with !important. */ +.agent-sessions-workbench.phone-layout .part.chatbar > .content, +.agent-sessions-workbench.phone-layout .part.sidebar > .content, +.agent-sessions-workbench.phone-layout .part.auxiliarybar > .content, +.agent-sessions-workbench.phone-layout .part.panel > .content { + width: 100% !important; +} + +/* Hide the session composite bar (Copilot CLI / Approvals / Branch) on phone */ +.agent-sessions-workbench.phone-layout .session-composite-bar { + display: none !important; +} + +/* Ensure the grid view element doesn't overflow — flex child must shrink */ +.agent-sessions-workbench.phone-layout > .monaco-grid-view { + flex: 1 1 0% !important; + min-height: 0 !important; + overflow: hidden !important; + height: auto !important; + background-color: var(--vscode-editor-background); +} + +/* Remove max-width constraint on chat content */ +.agent-sessions-workbench.phone-layout .interactive-session .interactive-item-container { + max-width: none !important; +} + +.agent-sessions-workbench.phone-layout .interactive-session > .chat-suggest-next-widget { + max-width: none !important; +} + +.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-part { + max-width: none !important; + padding-bottom: calc(10px + env(safe-area-inset-bottom)) !important; +} + +/* Chat input minimum font size to prevent iOS auto-zoom */ +.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container textarea, +.agent-sessions-workbench.phone-layout .interactive-session .chat-input-container input { + font-size: 16px !important; +} + +/* Hide the desktop titlebar on phone — replaced by mobile top bar */ +.agent-sessions-workbench.phone-layout .part.titlebar { + display: none !important; +} + +/* Sidebar content and customization toolbar should stack and scroll */ +.agent-sessions-workbench.phone-layout .part.sidebar { + display: flex !important; + flex-direction: column !important; + overflow: hidden !important; +} + +.agent-sessions-workbench.phone-layout .part.sidebar > .composite.title { + display: none !important; +} + +.agent-sessions-workbench.phone-layout .part.sidebar > .content { + top: 0 !important; + flex: 1 !important; + min-height: 0 !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; +} + +/* Customization toolbar: hidden on phone (opens editors, not mobile-compatible) */ +.agent-sessions-workbench.phone-layout .part.sidebar .ai-customization-toolbar { + display: none !important; +} + +/* Make sidebar footer touch-friendly */ +.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { + min-height: 44px; + padding: 8px 12px; +} + +/* Hide the "+ Session" button in the sidebar on phone — replaced by top bar + button */ +.agent-sessions-workbench.phone-layout .agent-sessions-new-button-container { + display: none !important; +} + +/* Hide sashes on phone */ +.agent-sessions-workbench.phone-layout .monaco-sash { + display: none !important; + pointer-events: none !important; +} + +/* Overscroll containment */ +.agent-sessions-workbench.mobile-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.mobile-layout .monaco-list { + overscroll-behavior: contain; +} + +/* On phone, push the chat input to the bottom of the chat area */ +.agent-sessions-workbench.phone-layout .interactive-session .interactive-input-and-execute-toolbar { + margin-top: auto !important; +} + +/* ---- Phone Layout: Chat Welcome Page ---- */ + +/* Make the welcome page a flex column that fills the chat area */ +.agent-sessions-workbench.phone-layout .chat-full-welcome { + display: flex !important; + flex-direction: column !important; + height: 100% !important; + padding: 8px 8px 0 8px !important; +} + +.agent-sessions-workbench.phone-layout .chat-full-welcome-content { + display: flex !important; + flex-direction: column !important; + flex: 1 !important; + min-height: 0 !important; + max-width: 100% !important; + padding-bottom: 20px !important; +} + +/* Workspace picker centered vertically with icon above */ +.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers-container { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + max-width: 100% !important; +} + +/* Show the sessions logo above the workspace picker — same asset as the auth page */ +.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers-container::before { + content: ''; + display: block; + width: 64px; + height: 64px; + margin-bottom: 16px; + background-image: url('../../../../sessions/contrib/welcome/browser/media/sessions-logo-light.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* Center the picker text */ +.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 8px !important; + font-size: 16px !important; +} + +.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers-label { + font-size: 18px !important; + opacity: 0.6; +} + +/* Input slot pinned to the bottom */ +.agent-sessions-workbench.phone-layout .chat-full-welcome-inputSlot { + flex-shrink: 0 !important; + padding: 0 0 8px 0 !important; + max-width: 100% !important; +} + +/* Make the chat input full-width and edge-to-edge styled */ +.agent-sessions-workbench.phone-layout .sessions-chat-input-area { + border-radius: 16px !important; + max-width: 100% !important; +} + +/* Hide the local mode bar (Copilot CLI / Default Approvals / Branch) on phone */ +.agent-sessions-workbench.phone-layout .chat-full-welcome-local-mode { + display: none !important; +} + +/* Also hide the sessions-chat-widget's DnD overlay on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-dnd-overlay { + display: none !important; +} + +/* Chat widget fills full width on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-widget { + width: 100% !important; +} + +/* allow-any-unicode-next-line */ +/* Compact chat toolbar on phone */ +.agent-sessions-workbench.phone-layout .sessions-chat-toolbar { + padding: 0 6px 0 6px !important; + max-height: 32px !important; + gap: 4px !important; +} + +/* Prevent card transitions from flashing on phone */ +.agent-sessions-workbench.phone-layout .part.chatbar, +.agent-sessions-workbench.phone-layout .part.sidebar, +.agent-sessions-workbench.phone-layout .part.auxiliarybar, +.agent-sessions-workbench.phone-layout .part.panel { + transition: none !important; +} diff --git a/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts new file mode 100644 index 0000000000000..fdc8a07854a89 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { Part } from '../../../../workbench/browser/part.js'; +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { PanelPart } from '../panelPart.js'; + +/** + * Mobile variant of PanelPart. + * + * On phone-sized viewports the panel fills the full grid cell + * without card margins or border insets. + */ +export class MobilePanelPart extends PanelPart { + + override updateStyles(): void { + // Run base theme wiring (skips PanelPart's card-specific inline styles) + AbstractPaneCompositePart.prototype.updateStyles.call(this); + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.PANEL_PART)) { + return; + } + + // Full dimensions — no card margins or border subtraction + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + Part.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts new file mode 100644 index 0000000000000..119ebc1532cd6 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; +import { SidebarPart } from '../sidebarPart.js'; + +/** + * Mobile variant of SidebarPart. + * + * On phone-sized viewports the sidebar skips card-specific inline styles + * so that CSS-only theming takes over. + */ +export class MobileSidebarPart extends SidebarPart { + + override updateStyles(): void { + // Run base theme wiring (skips SidebarPart's card / title-area inline styles) + AbstractPaneCompositePart.prototype.updateStyles.call(this); + + const container = this.getContainer(); + if (container) { + container.style.backgroundColor = ''; + container.style.color = ''; + container.style.outlineColor = ''; + } + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts new file mode 100644 index 0000000000000..78dd4e6c2b21f --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './mobileChatShell.css'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; + +/** + * Mobile top bar component — a simple DOM element prepended to the + * workbench container on phone viewports. Replaces the desktop titlebar + * with a native-feeling mobile app bar. + * + * Layout: [hamburger] [session title] [+ new] + */ +export class MobileTopBar extends Disposable { + + readonly element: HTMLElement; + + private readonly sessionTitleElement: HTMLElement; + + private readonly _onDidClickHamburger = this._register(new Emitter()); + readonly onDidClickHamburger: Event = this._onDidClickHamburger.event; + + private readonly _onDidClickNewSession = this._register(new Emitter()); + readonly onDidClickNewSession: Event = this._onDidClickNewSession.event; + + private readonly _onDidClickTitle = this._register(new Emitter()); + readonly onDidClickTitle: Event = this._onDidClickTitle.event; + + constructor(parent: HTMLElement) { + super(); + + this.element = document.createElement('div'); + this.element.className = 'mobile-top-bar'; + parent.prepend(this.element); + + // Hamburger button + const hamburger = append(this.element, $('button.mobile-top-bar-button')); + hamburger.setAttribute('aria-label', 'Open sessions'); + const hamburgerIcon = append(hamburger, $('span')); + hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu)); + hamburger.addEventListener('click', () => this._onDidClickHamburger.fire()); + + // Session title + this.sessionTitleElement = append(this.element, $('div.mobile-session-title')); + this.sessionTitleElement.textContent = 'New Session'; + this.sessionTitleElement.addEventListener('click', () => this._onDidClickTitle.fire()); + + // New session button (+) + const newSession = append(this.element, $('button.mobile-top-bar-button')); + newSession.setAttribute('aria-label', 'New session'); + const newSessionIcon = append(newSession, $('span')); + newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); + newSession.addEventListener('click', () => this._onDidClickNewSession.fire()); + + this._register({ dispose: () => this.element.remove() }); + } + + setTitle(title: string): void { + this.sessionTitleElement.textContent = title; + } +} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b49b41647c83b..8915afce7683c 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -12,7 +12,7 @@ import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; import { onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; -import { isWindows, isLinux, isWeb, isNative, isMacintosh, isMobile } from '../../base/common/platform.js'; +import { isWindows, isLinux, isWeb, isNative, isMacintosh } from '../../base/common/platform.js'; import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString } from '../../workbench/services/layout/browser/layoutService.js'; import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; import { Part } from '../../workbench/browser/part.js'; @@ -63,12 +63,17 @@ import { SyncDescriptor } from '../../platform/instantiation/common/descriptors. import { TitleService } from './parts/titlebarPart.js'; import { SessionsExperimentalSendButtonGradientSettingId, SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js'; import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; -import { EditorMaximizedContext } from '../common/contextkeys.js'; +import { EditorMaximizedContext, IsPhoneLayoutContext, KeyboardVisibleContext } from '../common/contextkeys.js'; import { NotificationsPosition, NotificationsSettings, getNotificationsPosition } from '../../workbench/common/notifications.js'; +import { SessionsLayoutPolicy } from './layoutPolicy.js'; +import { MobileNavigationStack } from './mobileNavigationStack.js'; +import { MobileTopBar } from './parts/mobile/mobileTopBar.js'; +import { autorun } from '../../base/common/observable.js'; +import { ISessionsManagementService } from '../services/sessions/common/sessionsManagement.js'; //#region Workbench Options @@ -93,7 +98,9 @@ enum LayoutClasses { EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND = 'experimental-shell-gradient-background', EXPERIMENTAL_SEND_BUTTON_GRADIENT = 'sessions-experimental-send-button-gradient', FULLSCREEN = 'fullscreen', - MAXIMIZED = 'maximized' + MAXIMIZED = 'maximized', + PHONE_LAYOUT = 'phone-layout', + MOBILE_LAYOUT = 'mobile-layout' } //#endregion @@ -207,6 +214,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private _mainContainerDimension!: IDimension; get mainContainerDimension(): IDimension { return this._mainContainerDimension; } + private readonly _themeColorMeta: HTMLMetaElement; + get activeContainerDimension(): IDimension { return this.getContainerDimension(this.activeContainer); } @@ -264,6 +273,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private mainWindowFullscreen = false; private readonly maximized = new Set(); + private readonly layoutPolicy = this._register(new SessionsLayoutPolicy()); + private readonly mobileNavStack = this._register(new MobileNavigationStack()); + private mobileTopBarElement: HTMLElement | undefined; + private readonly mobileTopBarDisposables = this._register(new DisposableStore()); private _editorMaximized = false; private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; @@ -282,6 +295,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private editorService!: IEditorService; private paneCompositeService!: IPaneCompositePartService; private viewDescriptorService!: IViewDescriptorService; + private sessionsManagementService!: ISessionsManagementService; //#endregion @@ -293,6 +307,28 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic ) { super(); + // Cache the theme-color meta element for dynamic updates + const themeColorMeta = mainWindow.document.createElement('meta'); + themeColorMeta.name = 'theme-color'; + themeColorMeta.content = '#1e1e1e'; + mainWindow.document.head.appendChild(themeColorMeta); + this._themeColorMeta = themeColorMeta; + + // Sessions-scoped mobile/PWA viewport tweaks. These are applied here + // (rather than in the shared workbench.html) so that the regular + // code-web workbench — which does not handle safe-area insets — is + // not affected on notched mobile devices. + const viewportMeta = mainWindow.document.querySelector('meta[name="viewport"]'); + if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { + viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; + } + if (!mainWindow.document.querySelector('link[rel="apple-touch-startup-image"]')) { + const startupImage = mainWindow.document.createElement('link'); + startupImage.rel = 'apple-touch-startup-image'; + startupImage.href = `${mainWindow.document.baseURI}favicon.ico`; + mainWindow.document.head.appendChild(startupImage); + } + // Perf: measure workbench startup time mark('code/willStartWorkbench'); @@ -392,6 +428,65 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editorMaximizedContext.set(this.isEditorMaximized()); })); + // Mobile Layout Context Keys + const contextKeyService = accessor.get(IContextKeyService); + const viewportClassCtx = ViewportClassContext.bindTo(contextKeyService); + const isMobileLayoutCtx = IsMobileLayoutContext.bindTo(contextKeyService); + this._register(autorun(reader => { + const vc = this.layoutPolicy.viewportClass.read(reader); + viewportClassCtx.set(vc); + isMobileLayoutCtx.set(vc === 'phone' || vc === 'tablet'); + })); + + // Virtual keyboard detection via visualViewport API + if (mainWindow.visualViewport) { + const keyboardVisibleCtx = KeyboardVisibleContext.bindTo(contextKeyService); + let initialViewportHeight = mainWindow.visualViewport.height; + + const onViewportResize = () => { + const vp = mainWindow.visualViewport; + if (!vp) { + return; + } + // Keyboard is considered visible when viewport shrinks by more than 150px + const heightDiff = initialViewportHeight - vp.height; + const isKeyboardUp = heightDiff > 150; + keyboardVisibleCtx.set(isKeyboardUp); + + // Update initial height if viewport grew (orientation change, not keyboard) + if (vp.height > initialViewportHeight) { + initialViewportHeight = vp.height; + } + }; + + mainWindow.visualViewport.addEventListener('resize', onViewportResize); + this._register({ dispose: () => mainWindow.visualViewport?.removeEventListener('resize', onViewportResize) }); + } + + // Orientation change: re-evaluate viewport class and re-layout + const orientationMediaQuery = mainWindow.matchMedia('(orientation: portrait)'); + let orientationRelayoutHandle: number | undefined; + const onOrientationChange = () => { + // Small delay to let the viewport settle after orientation change + if (orientationRelayoutHandle !== undefined) { + mainWindow.clearTimeout(orientationRelayoutHandle); + } + orientationRelayoutHandle = mainWindow.setTimeout(() => { + orientationRelayoutHandle = undefined; + this.layout(); + }, 100); + }; + orientationMediaQuery.addEventListener('change', onOrientationChange); + this._register({ + dispose: () => { + orientationMediaQuery.removeEventListener('change', onOrientationChange); + if (orientationRelayoutHandle !== undefined) { + mainWindow.clearTimeout(orientationRelayoutHandle); + orientationRelayoutHandle = undefined; + } + } + }); + // Register Listeners this.registerListeners(lifecycleService, storageService, configurationService, hostService, dialogService); @@ -401,6 +496,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Workbench Layout this.createWorkbenchLayout(); + // Create mobile navigation after grid exists (so DOM order is correct) + if (this.layoutPolicy.viewportClass.get() === 'phone') { + this.createMobileTopBar(); + } + // Workbench Management this.createWorkbenchManagement(instantiationService); @@ -560,6 +660,18 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic setARIAContainer(this.mainContainer); setProgressAccessibilitySignalScheduler((msDelayTime: number, msLoopTime?: number) => instantiationService.createInstance(AccessibilityProgressSignalScheduler, msDelayTime, msLoopTime)); + // Initialize viewport classification before building layout classes + const initialDimension = getClientArea(this.parent); + this.layoutPolicy.update(initialDimension.width, initialDimension.height); + + // Apply initial part visibility from layout policy (phone hides sidebar, etc.) + const visibilityDefaults = this.layoutPolicy.getPartVisibilityDefaults(); + this.partVisibility.sidebar = visibilityDefaults.sidebar; + this.partVisibility.auxiliaryBar = visibilityDefaults.auxiliaryBar; + this.partVisibility.panel = visibilityDefaults.panel; + this.partVisibility.chatBar = visibilityDefaults.chatBar; + this.partVisibility.editor = visibilityDefaults.editor; + // State specific classes const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; const workbenchClasses = coalesce([ @@ -607,6 +719,71 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.parent.appendChild(this.mainContainer); } + private createMobileTopBar(): void { + this.mobileTopBarDisposables.clear(); + const mobileTopBar = this.mobileTopBarDisposables.add(new MobileTopBar(this.mainContainer)); + this.mobileTopBarElement = mobileTopBar.element; + + // Hamburger: toggle sidebar drawer overlay + this.mobileTopBarDisposables.add(mobileTopBar.onDidClickHamburger(() => { + this.toggleMobileSidebarDrawer(); + })); + + // New session: open new chat view + this.mobileTopBarDisposables.add(mobileTopBar.onDidClickNewSession(() => { + this.sessionsManagementService.openNewSessionView(); + })); + } + + private sidebarDrawerBackdrop: HTMLElement | undefined; + + private toggleMobileSidebarDrawer(): void { + const isOpen = this.partVisibility.sidebar; + if (isOpen) { + this.closeMobileSidebarDrawer(); + } else { + this.openMobileSidebarDrawer(); + } + } + + private openMobileSidebarDrawer(): void { + // Show backdrop + if (!this.sidebarDrawerBackdrop) { + this.sidebarDrawerBackdrop = document.createElement('div'); + this.sidebarDrawerBackdrop.className = 'mobile-sidebar-backdrop'; + this.sidebarDrawerBackdrop.addEventListener('click', () => this.closeMobileSidebarDrawer()); + } + this.mainContainer.appendChild(this.sidebarDrawerBackdrop); + + // Push a history entry so the Android back button dismisses the drawer. + // Must come before setSideBarHidden(false) so layoutMobileSidebar() sees + // the drawer state. + if (!this.mobileNavStack.has('sidebar')) { + this.mobileNavStack.push('sidebar'); + } + + // Show sidebar in grid — the actual drawer dimensions are applied by + // layoutMobileSidebar() from within layout(), which respects the + // "drawer" shape on phone (85% width, below the mobile top bar). + this.setSideBarHidden(false); + } + + private closeMobileSidebarDrawer(): void { + // Remove backdrop + this.sidebarDrawerBackdrop?.remove(); + + // Hide sidebar in grid + this.setSideBarHidden(true); + + // Sync the navigation stack with the browser history: if there is a + // pending 'sidebar' entry (UI-initiated close), rewind history without + // firing onDidPop. If we're being called from the back-button path + // (onDidPop already fired), this is a no-op. + if (this.mobileNavStack.has('sidebar')) { + this.mobileNavStack.popSilently('sidebar'); + } + } + private createNotificationsHandlers( instantiationService: IInstantiationService, notificationService: NotificationService, @@ -756,6 +933,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.editorService = accessor.get(IEditorService); this.paneCompositeService = accessor.get(IPaneCompositePartService); this.viewDescriptorService = accessor.get(IViewDescriptorService); + this.sessionsManagementService = accessor.get(ISessionsManagementService); accessor.get(ITitleService); // Register layout listeners @@ -784,17 +962,15 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Initialize layout state (must be done before createWorkbenchLayout) this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); + this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); - // Default to list-detail on mobile web only. Desktop behavior stays unchanged, - // regardless of how narrow the window is resized. - if (isWeb && isMobile) { - this.partVisibility.sidebar = false; - this.partVisibility.auxiliaryBar = false; - } - } - - private isMobileWebLayout(): boolean { - return isWeb && isMobile; + // Update part visibility based on final viewport classification + const visDefaults = this.layoutPolicy.getPartVisibilityDefaults(); + this.partVisibility.sidebar = visDefaults.sidebar; + this.partVisibility.auxiliaryBar = visDefaults.auxiliaryBar; + this.partVisibility.panel = visDefaults.panel; + this.partVisibility.chatBar = visDefaults.chatBar; + this.partVisibility.editor = visDefaults.editor; } private areAllGroupsEmpty(): boolean { @@ -815,6 +991,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.layout(); } })); + + // Window resize — needed for device emulation and mobile viewport changes + const onWindowResize = () => this.layout(); + mainWindow.addEventListener('resize', onWindowResize); + this._register({ dispose: () => mainWindow.removeEventListener('resize', onWindowResize) }); } private updateFullscreenClass(): void { @@ -885,6 +1066,24 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); })); } + + // Wire up mobile nav stack: back-button pops close the corresponding part + this._register(this.mobileNavStack.onDidPop(layer => { + switch (layer) { + case 'sidebar': + this.closeMobileSidebarDrawer(); + break; + case 'panel': + this.setPanelHidden(true); + break; + case 'auxbar': + this.setAuxiliaryBarHidden(true); + break; + case 'editor': + // Editor modal close is handled by the editor service + break; + } + })); } createWorkbenchManagement(_instantiationService: IInstantiationService): void { @@ -904,25 +1103,43 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private createGridDescriptor(): ISerializedGrid { const { width, height } = this._mainContainerDimension; - // Default sizes - const sideBarSize = 300; + return this.createDesktopGridDescriptor(width, height); + } + + /** + * Standard multi-part layout for all viewport classes. + * On phone, the titlebar is hidden via CSS and a MobileTopBar + * is prepended before the grid. Sidebar/panel/auxbar are hidden + * in the grid via partVisibility defaults. + */ + private createDesktopGridDescriptor(width: number, height: number): ISerializedGrid { + + // Default sizes from layout policy + const sizes = this.layoutPolicy.getPartSizes(width, height); + // For hidden parts, still provide a reasonable cached size for when they're shown later + const sideBarSize = this.partVisibility.sidebar ? sizes.sideBarSize : Math.max(sizes.sideBarSize, 250); + const auxiliaryBarSize = this.partVisibility.auxiliaryBar ? sizes.auxiliaryBarSize : Math.max(sizes.auxiliaryBarSize, 300); + const panelSize = this.partVisibility.panel ? sizes.panelSize : Math.max(sizes.panelSize, 250); const editorSize = 650; - const auxiliaryBarSize = 380; - const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; - // Calculate right section width and chat bar width - const rightSectionWidth = Math.max(0, width - sideBarSize); - const chatBarWidth = Math.max(0, rightSectionWidth - auxiliaryBarSize - editorSize); + // Calculate right section width — when sidebar is hidden it takes no space + const effectiveSideBarWidth = this.partVisibility.sidebar ? sideBarSize : 0; + const rightSectionWidth = Math.max(0, width - effectiveSideBarWidth); + const effectiveAuxBarWidth = this.partVisibility.auxiliaryBar ? auxiliaryBarSize : 0; + const effectiveEditorWidth = this.partVisibility.editor ? editorSize : 0; + const chatBarWidth = Math.max(0, rightSectionWidth - effectiveAuxBarWidth - effectiveEditorWidth); const contentHeight = Math.max(0, height - titleBarHeight); const topRightHeight = Math.max(0, contentHeight - panelSize); + const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; + const titleBarNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.TITLEBAR_PART }, size: titleBarHeight, - visible: true + visible: !isPhone }; const sideBarNode: ISerializedLeafNode = { @@ -1002,20 +1219,86 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Layout Methods + private _previousViewportClass: string | undefined; + layout(): void { this._mainContainerDimension = getClientArea( this.mainWindowFullscreen ? mainWindow.document.body : this.parent ); + + // Update viewport classification and toggle mobile CSS classes + const previousClass = this._previousViewportClass; + this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); + const currentClass = this.layoutPolicy.viewportClass.get(); + this.mainContainer.classList.toggle(LayoutClasses.PHONE_LAYOUT, currentClass === 'phone'); + this.mainContainer.classList.toggle(LayoutClasses.MOBILE_LAYOUT, this.layoutPolicy.isMobileLayout.get()); + + // When viewport class changes at runtime (e.g., device emulation toggle), + // update part visibility and create/destroy mobile components + if (previousClass !== undefined && previousClass !== currentClass) { + if (currentClass === 'phone' && !this.mobileTopBarElement) { + this.createMobileTopBar(); + // Hide titlebar in grid on phone (replaced by MobileTopBar) + this.workbenchGrid.setViewVisible(this.titleBarPartView, false); + // On phone, only chat is visible — hide everything else first + const defaults = this.layoutPolicy.getPartVisibilityDefaults(); + if (this.partVisibility.sidebar !== defaults.sidebar) { + this.setSideBarHidden(!defaults.sidebar); + } + if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { + this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); + } + if (this.partVisibility.panel !== defaults.panel) { + this.setPanelHidden(!defaults.panel); + } + } else if (currentClass !== 'phone' && this.mobileTopBarElement) { + // Remove mobile components when leaving phone layout + this.mobileTopBarDisposables.clear(); + this.mobileTopBarElement = undefined; + // Restore titlebar in grid + this.workbenchGrid.setViewVisible(this.titleBarPartView, true); + // Restore desktop part visibility + const defaults = this.layoutPolicy.getPartVisibilityDefaults(); + if (this.partVisibility.sidebar !== defaults.sidebar) { + this.setSideBarHidden(!defaults.sidebar); + } + if (this.partVisibility.chatBar !== defaults.chatBar) { + this.setChatBarHidden(!defaults.chatBar); + } + if (this.partVisibility.auxiliaryBar !== defaults.auxiliaryBar) { + this.setAuxiliaryBarHidden(!defaults.auxiliaryBar); + } + if (this.partVisibility.panel !== defaults.panel) { + this.setPanelHidden(!defaults.panel); + } + } + } + this._previousViewportClass = currentClass; + this.logService.trace(`Workbench#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`); size(this.mainContainer, this._mainContainerDimension.width, this._mainContainerDimension.height); + // On phone, subtract the mobile top bar height from the grid + const mobileTopBarHeight = this.mobileTopBarElement?.offsetHeight ?? 0; + const gridHeight = this._mainContainerDimension.height - mobileTopBarHeight; + // Layout the grid widget - this.workbenchGrid.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + this.workbenchGrid.layout(this._mainContainerDimension.width, gridHeight); this.layoutMobileSidebar(); // Emit as event this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + + // Update mobile status bar theme color to match current theme + this.updateThemeColor(); + } + + private updateThemeColor(): void { + const bgColor = mainWindow.getComputedStyle(this.mainContainer).backgroundColor; + if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)') { + this._themeColorMeta.content = bgColor; + } } private layoutMobileSidebar(): void { @@ -1025,7 +1308,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } - if (!this.isMobileWebLayout() || !this.partVisibility.sidebar) { + // Only phone uses the overlay drawer shape. Tablet/desktop let the + // grid position the sidebar normally, so clear any inline styles. + const isPhone = this.layoutPolicy.viewportClass.get() === 'phone'; + if (!isPhone || !this.partVisibility.sidebar) { sidebarContainer.classList.remove('mobile-overlay-sidebar'); sidebarContainer.style.position = ''; sidebarContainer.style.top = ''; @@ -1036,17 +1322,19 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } - const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; - const mobileWidth = this._mainContainerDimension.width; - const mobileHeight = Math.max(0, this._mainContainerDimension.height - titleBarHeight); + // Phone drawer: 85% width (capped at 360px), positioned below the + // mobile top bar (the grid titlebar is hidden on phone). + const topBarHeight = this.mobileTopBarElement?.offsetHeight ?? 48; + const drawerWidth = Math.min(Math.floor(this._mainContainerDimension.width * 0.85), 360); + const drawerHeight = Math.max(0, this._mainContainerDimension.height - topBarHeight); sidebarContainer.classList.add('mobile-overlay-sidebar'); sidebarContainer.style.position = 'fixed'; - sidebarContainer.style.top = `${titleBarHeight}px`; + sidebarContainer.style.top = `${topBarHeight}px`; sidebarContainer.style.left = '0'; - sidebarContainer.style.width = `${mobileWidth}px`; - sidebarContainer.style.height = `${mobileHeight}px`; + sidebarContainer.style.width = `${drawerWidth}px`; + sidebarContainer.style.height = `${drawerHeight}px`; sidebarContainer.style.zIndex = '30'; - sidebarPart.layout(mobileWidth, mobileHeight, titleBarHeight, 0); + sidebarPart.layout(drawerWidth, drawerHeight, topBarHeight, 0); } private handleContainerDidLayout(container: HTMLElement, dimension: IDimension): void { @@ -1067,7 +1355,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, LayoutClasses.STATUSBAR_HIDDEN, // agents window never has a status bar - this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined + this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, + this.layoutPolicy.viewportClass.get() === 'phone' ? LayoutClasses.PHONE_LAYOUT : undefined, + this.layoutPolicy.isMobileLayout.get() ? LayoutClasses.MOBILE_LAYOUT : undefined, ]); } diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 385069bd358d7..ab53996dee424 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -38,3 +38,11 @@ export const SessionsWelcomeVisibleContext = new RawContextKey('session export const EditorMaximizedContext = new RawContextKey('editorMaximized', false, localize('editorMaximized', "Whether the editor area is maximized")); //#endregion + +//#region < --- Mobile Layout --- > + +export const ViewportClassContext = new RawContextKey('sessionsViewportClass', 'desktop', localize('sessionsViewportClass', "The current viewport class: phone, tablet, or desktop")); +export const IsMobileLayoutContext = new RawContextKey('sessionsIsMobileLayout', false, localize('sessionsIsMobileLayout', "Whether the current layout is mobile (phone or tablet)")); +export const KeyboardVisibleContext = new RawContextKey('sessionsKeyboardVisible', false, localize('sessionsKeyboardVisible', "Whether the virtual keyboard is visible")); + +//#endregion diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index f55765a4c93f8..761b61666449c 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -13,6 +13,7 @@ import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensi import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../common/changes.js'; import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import { ChangesTitleBarContribution } from './changesTitleBarWidget.js'; +import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; import './changesViewActions.js'; import './checksActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; @@ -54,6 +55,7 @@ viewsRegistry.registerViews([{ canMoveView: false, weight: 100, order: 1, + when: IsMobileLayoutContext.negate(), windowVisibility: WindowVisibility.Sessions, }], changesViewContainer); diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts index 022b52b7c64d8..ac5c321dbed16 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsMobileLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -42,7 +42,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsMobileLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index 067188f31e1c8..a5ab212878437 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsMobileLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -44,7 +44,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsMobileLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index cb943ea95b252..6d2380f9b84c1 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -14,6 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -52,6 +53,7 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp when: ContextKeyExpr.and( IsSessionsWindowContext, ChatContextKeys.agentSessionType.notEqualsTo(CopilotCloudSessionType.id), + IsMobileLayoutContext.negate(), ), }, ], diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts index f472c507c069f..b14a237722f92 100644 --- a/src/vs/sessions/contrib/files/browser/files.contribution.ts +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -19,6 +19,7 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; import { SESSIONS_FILES_EMPTY_VIEW_ID, SESSIONS_FILES_VIEW_ID, SessionsExplorerEmptyView, SessionsExplorerView } from './filesView.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; export const SESSIONS_FILES_CONTAINER_ID = 'workbench.sessions.auxiliaryBar.filesContainer'; @@ -60,7 +61,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerView), canToggleVisibility: false, canMoveView: false, - when: WorkspaceFolderCountContext.notEqualsTo('0'), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.notEqualsTo('0'), IsMobileLayoutContext.negate()), windowVisibility: WindowVisibility.Sessions, }], filesViewContainer); @@ -72,7 +73,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerEmptyView), canToggleVisibility: false, canMoveView: false, - when: WorkspaceFolderCountContext.isEqualTo('0'), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.isEqualTo('0'), IsMobileLayoutContext.negate()), windowVisibility: WindowVisibility.Sessions, }], filesViewContainer); } diff --git a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts index 8a562ce4977c8..eb78a01aadbef 100644 --- a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts +++ b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts @@ -14,6 +14,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { OutputViewPane } from '../../../../workbench/contrib/output/browser/outputView.js'; import { OUTPUT_VIEW_ID } from '../../../../workbench/services/output/common/output.js'; +import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; const SESSIONS_LOGS_CONTAINER_ID = 'workbench.sessions.panel.logsContainer'; @@ -59,6 +60,7 @@ class RegisterLogsViewContainerContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(OutputViewPane), canToggleVisibility: true, canMoveView: false, + when: IsMobileLayoutContext.negate(), windowVisibility: WindowVisibility.Sessions, }], logsViewContainer); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 884c9db5f6f87..2b706abba46e4 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -403,3 +403,23 @@ background-position: -120% 0; } } + +/* ---- Mobile Layout: Touch Adaptations ---- */ + +/* Always show inline toolbar on mobile (no hover dependency) */ +.agent-sessions-workbench.mobile-layout .sessions-list .monaco-list-row .actions { + display: flex !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Touch feedback on session list items */ +.agent-sessions-workbench.mobile-layout .sessions-list .monaco-list-row:active { + background-color: var(--vscode-list-hoverBackground) !important; +} + +/* Disable webkit touch callout on list items */ +.agent-sessions-workbench.mobile-layout .sessions-list .monaco-list-row { + -webkit-touch-callout: none; + touch-action: manipulation; +} diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 11dfd1e6446e1..99a07bb81c368 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -19,7 +19,7 @@ import { TerminalCapability } from '../../../../platform/terminal/common/capabil import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { SessionsWelcomeVisibleContext, IsMobileLayoutContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CopilotCLISessionType, ISession } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; @@ -392,7 +392,7 @@ class OpenSessionInTerminalAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsMobileLayoutContext.negate()), }] }); } From b9adf4e00d246d17b740df1d86e067fceaa461bb Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 21 Apr 2026 22:01:30 -0700 Subject: [PATCH 02/14] Remove out-of-scope changes from mobile PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove agentHost WebSocket implementation, webHostDiscovery contribution, welcome/walkthrough OAuth flow changes, PWA manifest updates, and lock file noise — none of these are related to mobile layout components for the agents workbench. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/package-lock.json | 7 +- resources/server/manifest.json | 3 - src/vs/code/browser/workbench/workbench.html | 2 +- .../browser/nullSshRemoteAgentHostService.ts | 36 -- .../browser/remoteAgentHostServiceImpl.ts | 565 ------------------ .../browser/webSocketClientTransport.ts | 161 ----- src/vs/sessions/sessions.web.main.ts | 3 + 7 files changed, 5 insertions(+), 772 deletions(-) delete mode 100644 src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts delete mode 100644 src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts delete mode 100644 src/vs/platform/agentHost/browser/webSocketClientTransport.ts diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index e7b122547ef81..aa768f0ebc26c 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -11515,7 +11515,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13731,7 +13730,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14593,8 +14591,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.1", @@ -15718,7 +15715,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -18759,7 +18755,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/resources/server/manifest.json b/resources/server/manifest.json index 71a66a8173bcb..443e3fd1cb1fd 100644 --- a/resources/server/manifest.json +++ b/resources/server/manifest.json @@ -5,9 +5,6 @@ "lang": "en-US", "display": "standalone", "display_override": ["window-controls-overlay"], - "background_color": "#1e1e1e", - "theme_color": "#1e1e1e", - "orientation": "any", "icons": [ { "src": "code-192.png", diff --git a/src/vs/code/browser/workbench/workbench.html b/src/vs/code/browser/workbench/workbench.html index 9401fd6cae924..7788198273570 100644 --- a/src/vs/code/browser/workbench/workbench.html +++ b/src/vs/code/browser/workbench/workbench.html @@ -14,7 +14,7 @@ - + diff --git a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts deleted file mode 100644 index 8edaea2d95500..0000000000000 --- a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts +++ /dev/null @@ -1,36 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import type { ISSHRemoteAgentHostService, ISSHAgentHostConnection, ISSHAgentHostConfig, ISSHConnectProgress, ISSHResolvedConfig } from '../common/sshRemoteAgentHost.js'; - -/** - * Null implementation of {@link ISSHRemoteAgentHostService} for browser contexts - * where SSH is not available. - */ -export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService { - declare readonly _serviceBrand: undefined; - readonly onDidChangeConnections = Event.None; - readonly onDidReportConnectProgress: Event = Event.None; - readonly connections: readonly ISSHAgentHostConnection[] = []; - - async connect(_config: ISSHAgentHostConfig): Promise { - throw new Error('SSH connections are not supported in the browser.'); - } - - async disconnect(_host: string): Promise { } - - async listSSHConfigHosts(): Promise { - return []; - } - - async resolveSSHConfig(_host: string): Promise { - throw new Error('SSH is not supported in the browser.'); - } - - async reconnect(_sshConfigHost: string, _name: string): Promise { - throw new Error('SSH connections are not supported in the browser.'); - } -} diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts deleted file mode 100644 index 3070a1744814d..0000000000000 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ /dev/null @@ -1,565 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// Service implementation that manages WebSocket connections to remote agent -// host processes. Reads addresses from the `chat.remoteAgentHosts` setting -// and maintains connections, reconnecting as the setting changes. - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; -import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; -import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { ILogService } from '../../log/common/log.js'; - -import type { IAgentConnection } from '../common/agentService.js'; -import { - IRemoteAgentHostService, - RemoteAgentHostConnectionStatus, - RemoteAgentHostEntryType, - RemoteAgentHostsEnabledSettingId, - RemoteAgentHostsSettingId, - entryToRawEntry, - getEntryAddress, - rawEntryToEntry, - type IRawRemoteAgentHostEntry, - type IRemoteAgentHostConnectionInfo, - type IRemoteAgentHostEntry, -} from '../common/remoteAgentHostService.js'; -import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; -import { WebSocketClientTransport } from './webSocketClientTransport.js'; -import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; -import { isDefined } from '../../../base/common/types.js'; - -/** Tracks a single remote connection through its lifecycle. */ -interface IConnectionEntry { - readonly store: DisposableStore; - readonly client: RemoteAgentHostProtocolClient; - connected: boolean; - /** Current connection status for UI display. */ - status: RemoteAgentHostConnectionStatus; -} - -export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService { - private static readonly ConnectionWaitTimeout = 10000; - /** Initial reconnect delay in milliseconds. */ - private static readonly ReconnectInitialDelay = 1000; - /** Maximum reconnect delay in milliseconds. */ - private static readonly ReconnectMaxDelay = 30000; - - declare readonly _serviceBrand: undefined; - - private readonly _onDidChangeConnections = this._register(new Emitter()); - readonly onDidChangeConnections = this._onDidChangeConnections.event; - - private readonly _entries = new Map(); - private readonly _names = new Map(); - private readonly _tokens = new Map(); - /** - * Stores the original {@link IRemoteAgentHostEntry} for connections - * registered via {@link addManagedConnection}. This is needed because - * tunnel entries are not persisted to settings and therefore don't - * appear in {@link configuredEntries}. - */ - private readonly _registeredEntries = new Map(); - private readonly _pendingConnectionWaits = new Map>(); - /** Pending reconnect timeouts, keyed by normalized address. */ - private readonly _reconnectTimeouts = new Map>(); - /** Current reconnect attempt count per address for exponential backoff. */ - private readonly _reconnectAttempts = new Map(); - - constructor( - @IConfigurationService private readonly _configurationService: IConfigurationService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ILogService private readonly _logService: ILogService, - ) { - super(); - - // React to setting changes - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { - this._reconcileConnections(); - } - })); - - // Initial connection - this._reconcileConnections(); - } - - get connections(): readonly IRemoteAgentHostConnectionInfo[] { - const result: IRemoteAgentHostConnectionInfo[] = []; - for (const [address, entry] of this._entries) { - result.push({ - address, - name: this._names.get(address) ?? address, - clientId: entry.client.clientId, - defaultDirectory: entry.client.defaultDirectory, - status: entry.status, - }); - } - return result; - } - - get configuredEntries(): readonly IRemoteAgentHostEntry[] { - return this._getConfiguredEntries().map(e => { - if (e.connection.type === RemoteAgentHostEntryType.Tunnel) { - return e; - } - return { ...e, connection: { ...e.connection, address: normalizeRemoteAgentHostAddress(e.connection.address) } }; - }); - } - - getConnection(address: string): IAgentConnection | undefined { - const normalized = normalizeRemoteAgentHostAddress(address); - const entry = this._entries.get(normalized); - return entry?.connected ? entry.client : undefined; - } - - getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined { - const normalized = normalizeRemoteAgentHostAddress(address); - // Check dynamically registered entries first (e.g. tunnel connections - // that are not persisted to settings). - const registered = this._registeredEntries.get(normalized); - if (registered) { - return registered; - } - // Fall back to configured entries from settings. - return this.configuredEntries.find( - e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized - ); - } - - reconnect(address: string): void { - const normalized = normalizeRemoteAgentHostAddress(address); - - // SSH/tunnel entries are reconnected by their respective services - const configuredEntry = this._getConfiguredEntries().find( - e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized - ); - if (configuredEntry && configuredEntry.connection.type !== RemoteAgentHostEntryType.WebSocket) { - return; - } - - const token = this._tokens.get(normalized); - - // Cancel any pending reconnect - this._cancelReconnect(normalized); - this._reconnectAttempts.delete(normalized); - - // Tear down existing connection if present - const entry = this._entries.get(normalized); - if (entry) { - this._entries.delete(normalized); - entry.store.dispose(); - } - - // Start fresh connection attempt - this._connectTo(normalized, token); - } - - async addRemoteAgentHost(input: IRemoteAgentHostEntry): Promise { - if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { - throw new Error('Remote agent host connections are not enabled.'); - } - - const entry: IRemoteAgentHostEntry = input.connection.type === RemoteAgentHostEntryType.Tunnel - ? input - : { ...input, connection: { ...input.connection, address: normalizeRemoteAgentHostAddress(input.connection.address) } }; - const address = getEntryAddress(entry); - const existingConnection = this._getConnectionInfo(address); - await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); - - if (existingConnection) { - return { - ...existingConnection, - name: entry.name, - }; - } - - // SSH entries are connected externally — just persist - // the entry and return a disconnected placeholder. The connection - // will be established by the SSH contribution. - if (entry.connection.type === RemoteAgentHostEntryType.SSH) { - return { - address, - name: entry.name, - clientId: '', - status: RemoteAgentHostConnectionStatus.Disconnected, - }; - } - - const connectedConnection = this._getConnectionInfo(address); - if (connectedConnection) { - return connectedConnection; - } - - const wait = this._getOrCreateConnectionWait(address); - const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => { - this._pendingConnectionWaits.delete(address); - }); - if (!connection) { - throw new Error(`Timed out connecting to ${address}`); - } - - return connection; - } - - async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise { - const address = getEntryAddress(entry); - - // Dispose any existing entry for this address to avoid leaking - // old protocol clients and relay transports on reconnect. - const existingEntry = this._entries.get(address); - if (existingEntry) { - this._entries.delete(address); - existingEntry.store.dispose(); - } - - const store = new DisposableStore(); - - // Create a connection entry wrapping the pre-connected client - const protocolClient = connection as RemoteAgentHostProtocolClient; - store.add(protocolClient); - // Tear the underlying transport (e.g. SSH/tunnel relay) down with - // the entry. This is what makes "Remove Remote" actually close the - // shared-process tunnel and stop the remote agent host process. - if (transportDisposable) { - store.add(transportDisposable); - } - const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected }; - this._entries.set(address, connEntry); - this._names.set(address, entry.name); - this._registeredEntries.set(address, entry); - if (entry.connectionToken) { - this._tokens.set(address, entry.connectionToken); - } - - store.add(protocolClient.onDidClose(() => { - if (this._entries.get(address) === connEntry) { - connEntry.connected = false; - connEntry.status = RemoteAgentHostConnectionStatus.Disconnected; - this._onDidChangeConnections.fire(); - } - })); - - // Persist entries — await so that the config is written before - // onDidChangeConnections fires, ensuring _reconcile creates the provider. - // Tunnel entries are filtered out by _storeConfiguredEntries automatically. - await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); - - this._onDidChangeConnections.fire(); - - return { - address, - name: entry.name, - clientId: protocolClient.clientId, - defaultDirectory: protocolClient.defaultDirectory, - status: RemoteAgentHostConnectionStatus.Connected, - }; - } - - async removeRemoteAgentHost(address: string): Promise { - const normalized = normalizeRemoteAgentHostAddress(address); - // This setting is only used in the sessions app (user scope), so we - // don't need to inspect per-scope values like _upsertConfiguredEntry does. - const entries = this._getConfiguredEntries().filter( - e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) !== normalized - ); - await this._storeConfiguredEntries(entries); - - // Eagerly clear in-memory state so the UI updates immediately - // (the config change listener will reconcile, but this is instant). - this._names.delete(normalized); - this._tokens.delete(normalized); - this._registeredEntries.delete(normalized); - this._cancelReconnect(normalized); - this._reconnectAttempts.delete(normalized); - this._removeConnection(normalized); - } - - private _removeConnection(address: string): void { - const entry = this._entries.get(address); - if (entry) { - this._entries.delete(address); - entry.store.dispose(); - this._rejectPendingConnectionWait(address, new Error(`Connection closed: ${address}`)); - this._onDidChangeConnections.fire(); - } - } - - private _reconcileConnections(): void { - if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { - // Disconnect all when disabled - for (const address of [...this._entries.keys()]) { - this._cancelReconnect(address); - this._removeConnection(address); - } - this._names.clear(); - this._tokens.clear(); - this._reconnectAttempts.clear(); - return; - } - - const rawEntries = (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); - const entriesWithAddress = rawEntries.map(e => ({ entry: e, address: normalizeRemoteAgentHostAddress(getEntryAddress(e)) })); - const desired = new Set(entriesWithAddress.map(e => e.address)); - - this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`); - - // Update name map and detect name changes for existing connections - let namesChanged = false; - const oldNames = new Map(this._names); - this._names.clear(); - this._tokens.clear(); - for (const { entry, address } of entriesWithAddress) { - this._names.set(address, entry.name); - this._tokens.set(address, entry.connectionToken); - if (this._entries.has(address) && oldNames.get(address) !== entry.name) { - namesChanged = true; - } - } - - // Remove connections no longer in the setting - for (const address of [...this._entries.keys()]) { - if (!desired.has(address)) { - this._logService.info(`[RemoteAgentHost] Disconnecting from ${address}`); - this._cancelReconnect(address); - this._reconnectAttempts.delete(address); - this._removeConnection(address); - } - } - - // Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService, - // and skip tunnel entries — those are handled by ITunnelAgentHostService) - for (const { entry, address } of entriesWithAddress) { - if (!this._entries.has(address) && entry.connection.type === RemoteAgentHostEntryType.WebSocket) { - this._connectTo(address, entry.connectionToken); - } - } - - // If only names changed (no add/remove), notify so the UI updates - if (namesChanged) { - this._onDidChangeConnections.fire(); - } - } - - private _connectTo(address: string, connectionToken?: string): void { - // Dispose any existing entry for this address before creating a new one - // to avoid leaking disposables on reconnect. - const existingEntry = this._entries.get(address); - if (existingEntry) { - this._entries.delete(address); - existingEntry.store.dispose(); - } - - const store = new DisposableStore(); - const transport = store.add(new WebSocketClientTransport(address, connectionToken)); - const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport)); - const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting }; - this._entries.set(address, entry); - - // Guard against stale callbacks: only act if the - // current entry for this address is still the one we created. - const isCurrentEntry = () => this._entries.get(address) === entry; - - store.add(client.onDidClose(() => { - if (!isCurrentEntry()) { - return; - } - this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`); - entry.connected = false; - entry.status = RemoteAgentHostConnectionStatus.Disconnected; - this._onDidChangeConnections.fire(); - // Schedule reconnect if the address is still configured - this._scheduleReconnect(address, connectionToken); - })); - - this._logService.info(`[RemoteAgentHost] Connecting to ${address}`); - this._onDidChangeConnections.fire(); - client.connect().then(() => { - if (store.isDisposed) { - return; // removed before connect resolved - } - this._logService.info(`[RemoteAgentHost] Connected to ${address}`); - entry.connected = true; - entry.status = RemoteAgentHostConnectionStatus.Connected; - this._reconnectAttempts.delete(address); - this._resolvePendingConnectionWait(address); - this._onDidChangeConnections.fire(); - }).catch((err: unknown) => { - if (!isCurrentEntry()) { - return; - } - this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}. Verify address and connectionToken`, err); - entry.status = RemoteAgentHostConnectionStatus.Disconnected; - // Clean up the failed entry - this._entries.delete(address); - entry.store.dispose(); - this._rejectPendingConnectionWait(address, err); - this._onDidChangeConnections.fire(); - // Schedule reconnect if the address is still configured - this._scheduleReconnect(address, connectionToken); - }); - } - - /** - * Schedule a reconnect attempt with exponential backoff. - * Only reconnects if the address is still in the configured entries. - */ - private _scheduleReconnect(address: string, connectionToken?: string): void { - // Don't reconnect if the address was removed from settings - if (!this._isAddressConfigured(address)) { - this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: no longer configured`); - return; - } - - const attempt = (this._reconnectAttempts.get(address) ?? 0) + 1; - this._reconnectAttempts.set(address, attempt); - const delay = Math.min( - RemoteAgentHostService.ReconnectInitialDelay * Math.pow(2, attempt - 1), - RemoteAgentHostService.ReconnectMaxDelay, - ); - - this._logService.info(`[RemoteAgentHost] Scheduling reconnect to ${address} in ${delay}ms (attempt ${attempt})`); - - this._cancelReconnect(address); - const timeout = setTimeout(() => { - this._reconnectTimeouts.delete(address); - if (this._isAddressConfigured(address)) { - this._connectTo(address, connectionToken ?? this._tokens.get(address)); - } - }, delay); - this._reconnectTimeouts.set(address, timeout); - } - - /** Cancel a pending reconnect timeout for the given address. */ - private _cancelReconnect(address: string): void { - const timeout = this._reconnectTimeouts.get(address); - if (timeout !== undefined) { - clearTimeout(timeout); - this._reconnectTimeouts.delete(address); - } - } - - /** Check whether the given normalized address is still in the configured entries. */ - private _isAddressConfigured(address: string): boolean { - const entries = this._getConfiguredEntries(); - return entries.some(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === address); - } - - private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined { - return this.connections.find(connection => connection.address === address && connection.status === RemoteAgentHostConnectionStatus.Connected); - } - - private _getConfiguredEntries(): IRemoteAgentHostEntry[] { - return (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); - } - - private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] { - // Read from the same scope we'll write to, so we don't accidentally - // merge entries from an overriding scope (e.g. workspace) into the - // user scope and then lose them on the next read. - const target = this._getConfigurationTarget(); - const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); - let configuredRaw: readonly IRawRemoteAgentHostEntry[]; - switch (target) { - case ConfigurationTarget.USER_LOCAL: - configuredRaw = inspected.userLocalValue ?? []; - break; - case ConfigurationTarget.USER_REMOTE: - configuredRaw = inspected.userRemoteValue ?? []; - break; - default: - configuredRaw = inspected.userValue ?? []; - break; - } - - const configuredEntries = configuredRaw.map(rawEntryToEntry).filter((e): e is IRemoteAgentHostEntry => e !== undefined); - const normalizedAddress = normalizeRemoteAgentHostAddress(getEntryAddress(entry)); - const existingIndex = configuredEntries.findIndex(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalizedAddress); - if (existingIndex === -1) { - return [...configuredEntries, entry]; - } - - return configuredEntries.map((e, index) => index === existingIndex ? entry : e); - } - - private _getConfigurationTarget(): ConfigurationTarget { - const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); - if (inspected.userLocalValue !== undefined) { - return ConfigurationTarget.USER_LOCAL; - } - if (inspected.userRemoteValue !== undefined) { - return ConfigurationTarget.USER_REMOTE; - } - if (inspected.userValue !== undefined) { - return ConfigurationTarget.USER; - } - return ConfigurationTarget.USER; - } - - private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise { - const raw = entries.map(entryToRawEntry).filter(isDefined); - await this._configurationService.updateValue(RemoteAgentHostsSettingId, raw, this._getConfigurationTarget()); - } - - private _getOrCreateConnectionWait(address: string): DeferredPromise { - let wait = this._pendingConnectionWaits.get(address); - if (wait) { - return wait; - } - - // If the connection is already available (fast connect resolved before - // the caller called us), return an immediately-completed wait. - const existingConnection = this._getConnectionInfo(address); - if (existingConnection) { - const immediateWait = new DeferredPromise(); - immediateWait.complete(existingConnection); - return immediateWait; - } - - wait = new DeferredPromise(); - this._pendingConnectionWaits.set(address, wait); - return wait; - } - - private _resolvePendingConnectionWait(address: string): void { - const wait = this._pendingConnectionWaits.get(address); - const connection = this._getConnectionInfo(address); - if (!wait || !connection) { - return; - } - - this._pendingConnectionWaits.delete(address); - void wait.complete(connection); - } - - private _rejectPendingConnectionWait(address: string, err: unknown): void { - const wait = this._pendingConnectionWaits.get(address); - if (!wait) { - return; - } - - this._pendingConnectionWaits.delete(address); - void wait.error(err); - } - - override dispose(): void { - for (const timeout of this._reconnectTimeouts.values()) { - clearTimeout(timeout); - } - this._reconnectTimeouts.clear(); - this._reconnectAttempts.clear(); - for (const [address, wait] of this._pendingConnectionWaits) { - void wait.error(new Error(`Remote agent host service disposed before connecting to ${address}`)); - } - this._pendingConnectionWaits.clear(); - for (const entry of this._entries.values()) { - entry.store.dispose(); - } - this._entries.clear(); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts deleted file mode 100644 index 6d01fe3e41614..0000000000000 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ /dev/null @@ -1,161 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// WebSocket client transport for connecting to remote agent host processes. -// Uses plain JSON serialization — URIs are string-typed in the protocol. - -import { Emitter } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { connectionTokenQueryName } from '../../../base/common/network.js'; -import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; -import type { IClientTransport } from '../common/state/sessionTransport.js'; -import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; - -// ---- Client transport ------------------------------------------------------- - -/** - * A WebSocket client transport that connects to a remote agent host server. - * Uses the native browser WebSocket API (available in Electron renderer). - * Implements {@link IClientTransport} with JSON serialization and URI revival. - */ -export class WebSocketClientTransport extends Disposable implements IClientTransport { - - private readonly _onMessage = this._register(new Emitter()); - readonly onMessage = this._onMessage.event; - - private readonly _onClose = this._register(new Emitter()); - readonly onClose = this._onClose.event; - - private readonly _onOpen = this._register(new Emitter()); - readonly onOpen = this._onOpen.event; - - private _ws: WebSocket | undefined; - private _malformedFrames = 0; - - get isOpen(): boolean { - return this._ws?.readyState === WebSocket.OPEN; - } - - constructor( - private readonly _address: string, - private readonly _connectionToken?: string, - ) { - super(); - } - - /** - * Initiate the WebSocket connection. Resolves when the connection - * is open, or rejects on error/timeout. - */ - connect(): Promise { - return new Promise((resolve, reject) => { - if (this._store.isDisposed) { - reject(new Error('Transport is disposed')); - return; - } - - let url = this._address.startsWith('ws://') || this._address.startsWith('wss://') - ? this._address - : `ws://${this._address}`; - - if (this._connectionToken) { - const separator = url.includes('?') ? '&' : '?'; - url += `${separator}${connectionTokenQueryName}=${encodeURIComponent(this._connectionToken)}`; - } - - const ws = new WebSocket(url); - this._ws = ws; - - const onOpen = () => { - cleanup(); - this._onOpen.fire(); - resolve(); - }; - - const onError = () => { - cleanup(); - reject(new Error(`WebSocket connection failed: ${this._address}`)); - }; - - const onClose = () => { - cleanup(); - reject(new Error(`WebSocket closed before connection was established: ${this._address}`)); - }; - - const cleanup = () => { - ws.removeEventListener('open', onOpen); - ws.removeEventListener('error', onError); - ws.removeEventListener('close', onClose); - }; - - ws.addEventListener('open', onOpen); - ws.addEventListener('error', onError); - ws.addEventListener('close', onClose); - - // Wire up long-lived listeners after connection - ws.addEventListener('message', (event: MessageEvent) => { - if (typeof event.data !== 'string') { - this._malformedFrames++; - if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { - const dataType = event.data instanceof ArrayBuffer ? 'ArrayBuffer' : event.data instanceof Blob ? 'Blob' : typeof event.data; - const byteLen = event.data instanceof ArrayBuffer ? event.data.byteLength : event.data instanceof Blob ? event.data.size : 0; - console.warn( - `[WebSocketClientTransport] Non-string frame #${this._malformedFrames} (type=${dataType}, bytes=${byteLen})` - ); - } - if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { - console.warn( - `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` - ); - this._ws?.close(4002, 'malformed-frames'); - } - return; - } - const text = event.data; - let message: ProtocolMessage; - try { - message = JSON.parse(text) as ProtocolMessage; - } catch (err) { - this._malformedFrames++; - if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { - const preview = text.length > 80 ? text.slice(0, 80) + '…' : text; - console.warn( - `[WebSocketClientTransport] Malformed frame #${this._malformedFrames} (len=${text.length}): ${preview}`, - err instanceof Error ? err.message : String(err) - ); - } - if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { - console.warn( - `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` - ); - this._ws?.close(4002, 'malformed-frames'); - } - return; - } - this._onMessage.fire(message); - }); - - ws.addEventListener('close', () => { - this._onClose.fire(); - }); - - ws.addEventListener('error', () => { - // Error always precedes close - closing is handled in the close handler. - this._onClose.fire(); - }); - }); - } - - send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { - if (this._ws?.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify(message)); - } - } - - override dispose(): void { - this._ws?.close(); - super.dispose(); - } -} diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 6abba66db2c46..2bf9d8d1edf16 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -160,6 +160,9 @@ import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; // Host filter dropdown in the titlebar (scopes the sessions list to a host) import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; +// Host filter dropdown in the titlebar (scopes the sessions list to a host) +import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; + // TODO: support agent feedback in web import './contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.js'; import '../workbench/contrib/webview/browser/webview.web.contribution.js'; From 84f9322b61a2f81062f58e645aa4382027421869 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Tue, 21 Apr 2026 22:13:44 -0700 Subject: [PATCH 03/14] Restore agentHost browser files incorrectly deleted These files exist in main and were not introduced by this PR. They were mistakenly removed during out-of-scope cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/nullSshRemoteAgentHostService.ts | 36 ++ .../browser/remoteAgentHostServiceImpl.ts | 559 ++++++++++++++++++ .../browser/webSocketClientTransport.ts | 161 +++++ 3 files changed, 756 insertions(+) create mode 100644 src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts create mode 100644 src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts create mode 100644 src/vs/platform/agentHost/browser/webSocketClientTransport.ts diff --git a/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts new file mode 100644 index 0000000000000..8edaea2d95500 --- /dev/null +++ b/src/vs/platform/agentHost/browser/nullSshRemoteAgentHostService.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import type { ISSHRemoteAgentHostService, ISSHAgentHostConnection, ISSHAgentHostConfig, ISSHConnectProgress, ISSHResolvedConfig } from '../common/sshRemoteAgentHost.js'; + +/** + * Null implementation of {@link ISSHRemoteAgentHostService} for browser contexts + * where SSH is not available. + */ +export class NullSSHRemoteAgentHostService implements ISSHRemoteAgentHostService { + declare readonly _serviceBrand: undefined; + readonly onDidChangeConnections = Event.None; + readonly onDidReportConnectProgress: Event = Event.None; + readonly connections: readonly ISSHAgentHostConnection[] = []; + + async connect(_config: ISSHAgentHostConfig): Promise { + throw new Error('SSH connections are not supported in the browser.'); + } + + async disconnect(_host: string): Promise { } + + async listSSHConfigHosts(): Promise { + return []; + } + + async resolveSSHConfig(_host: string): Promise { + throw new Error('SSH is not supported in the browser.'); + } + + async reconnect(_sshConfigHost: string, _name: string): Promise { + throw new Error('SSH connections are not supported in the browser.'); + } +} diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts new file mode 100644 index 0000000000000..f14d1b42b602c --- /dev/null +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -0,0 +1,559 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Service implementation that manages WebSocket connections to remote agent +// host processes. Reads addresses from the `chat.remoteAgentHosts` setting +// and maintains connections, reconnecting as the setting changes. + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; +import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; + +import type { IAgentConnection } from '../common/agentService.js'; +import { + IRemoteAgentHostService, + RemoteAgentHostConnectionStatus, + RemoteAgentHostEntryType, + RemoteAgentHostsEnabledSettingId, + RemoteAgentHostsSettingId, + entryToRawEntry, + getEntryAddress, + rawEntryToEntry, + type IRawRemoteAgentHostEntry, + type IRemoteAgentHostConnectionInfo, + type IRemoteAgentHostEntry, +} from '../common/remoteAgentHostService.js'; +import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; +import { WebSocketClientTransport } from './webSocketClientTransport.js'; +import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; +import { isDefined } from '../../../base/common/types.js'; + +/** Tracks a single remote connection through its lifecycle. */ +interface IConnectionEntry { + readonly store: DisposableStore; + readonly client: RemoteAgentHostProtocolClient; + connected: boolean; + /** Current connection status for UI display. */ + status: RemoteAgentHostConnectionStatus; +} + +export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService { + private static readonly ConnectionWaitTimeout = 10000; + /** Initial reconnect delay in milliseconds. */ + private static readonly ReconnectInitialDelay = 1000; + /** Maximum reconnect delay in milliseconds. */ + private static readonly ReconnectMaxDelay = 30000; + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeConnections = this._register(new Emitter()); + readonly onDidChangeConnections = this._onDidChangeConnections.event; + + private readonly _entries = new Map(); + private readonly _names = new Map(); + private readonly _tokens = new Map(); + /** + * Stores the original {@link IRemoteAgentHostEntry} for connections + * registered via {@link addSSHConnection}. This is needed because + * tunnel entries are not persisted to settings and therefore don't + * appear in {@link configuredEntries}. + */ + private readonly _registeredEntries = new Map(); + private readonly _pendingConnectionWaits = new Map>(); + /** Pending reconnect timeouts, keyed by normalized address. */ + private readonly _reconnectTimeouts = new Map>(); + /** Current reconnect attempt count per address for exponential backoff. */ + private readonly _reconnectAttempts = new Map(); + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // React to setting changes + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + this._reconcileConnections(); + } + })); + + // Initial connection + this._reconcileConnections(); + } + + get connections(): readonly IRemoteAgentHostConnectionInfo[] { + const result: IRemoteAgentHostConnectionInfo[] = []; + for (const [address, entry] of this._entries) { + result.push({ + address, + name: this._names.get(address) ?? address, + clientId: entry.client.clientId, + defaultDirectory: entry.client.defaultDirectory, + status: entry.status, + }); + } + return result; + } + + get configuredEntries(): readonly IRemoteAgentHostEntry[] { + return this._getConfiguredEntries().map(e => { + if (e.connection.type === RemoteAgentHostEntryType.Tunnel) { + return e; + } + return { ...e, connection: { ...e.connection, address: normalizeRemoteAgentHostAddress(e.connection.address) } }; + }); + } + + getConnection(address: string): IAgentConnection | undefined { + const normalized = normalizeRemoteAgentHostAddress(address); + const entry = this._entries.get(normalized); + return entry?.connected ? entry.client : undefined; + } + + getEntryByAddress(address: string): IRemoteAgentHostEntry | undefined { + const normalized = normalizeRemoteAgentHostAddress(address); + // Check dynamically registered entries first (e.g. tunnel connections + // that are not persisted to settings). + const registered = this._registeredEntries.get(normalized); + if (registered) { + return registered; + } + // Fall back to configured entries from settings. + return this.configuredEntries.find( + e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized + ); + } + + reconnect(address: string): void { + const normalized = normalizeRemoteAgentHostAddress(address); + + // SSH/tunnel entries are reconnected by their respective services + const configuredEntry = this._getConfiguredEntries().find( + e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized + ); + if (configuredEntry && configuredEntry.connection.type !== RemoteAgentHostEntryType.WebSocket) { + return; + } + + const token = this._tokens.get(normalized); + + // Cancel any pending reconnect + this._cancelReconnect(normalized); + this._reconnectAttempts.delete(normalized); + + // Tear down existing connection if present + const entry = this._entries.get(normalized); + if (entry) { + this._entries.delete(normalized); + entry.store.dispose(); + } + + // Start fresh connection attempt + this._connectTo(normalized, token); + } + + async addRemoteAgentHost(input: IRemoteAgentHostEntry): Promise { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + throw new Error('Remote agent host connections are not enabled.'); + } + + const entry: IRemoteAgentHostEntry = input.connection.type === RemoteAgentHostEntryType.Tunnel + ? input + : { ...input, connection: { ...input.connection, address: normalizeRemoteAgentHostAddress(input.connection.address) } }; + const address = getEntryAddress(entry); + const existingConnection = this._getConnectionInfo(address); + await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); + + if (existingConnection) { + return { + ...existingConnection, + name: entry.name, + }; + } + + // SSH entries are connected externally — just persist + // the entry and return a disconnected placeholder. The connection + // will be established by the SSH contribution. + if (entry.connection.type === RemoteAgentHostEntryType.SSH) { + return { + address, + name: entry.name, + clientId: '', + status: RemoteAgentHostConnectionStatus.Disconnected, + }; + } + + const connectedConnection = this._getConnectionInfo(address); + if (connectedConnection) { + return connectedConnection; + } + + const wait = this._getOrCreateConnectionWait(address); + const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => { + this._pendingConnectionWaits.delete(address); + }); + if (!connection) { + throw new Error(`Timed out connecting to ${address}`); + } + + return connection; + } + + async addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise { + const address = getEntryAddress(entry); + + // Dispose any existing entry for this address to avoid leaking + // old protocol clients and relay transports on reconnect. + const existingEntry = this._entries.get(address); + if (existingEntry) { + this._entries.delete(address); + existingEntry.store.dispose(); + } + + const store = new DisposableStore(); + + // Create a connection entry wrapping the pre-connected client + const protocolClient = connection as RemoteAgentHostProtocolClient; + store.add(protocolClient); + const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected }; + this._entries.set(address, connEntry); + this._names.set(address, entry.name); + this._registeredEntries.set(address, entry); + if (entry.connectionToken) { + this._tokens.set(address, entry.connectionToken); + } + + store.add(protocolClient.onDidClose(() => { + if (this._entries.get(address) === connEntry) { + connEntry.connected = false; + connEntry.status = RemoteAgentHostConnectionStatus.Disconnected; + this._onDidChangeConnections.fire(); + } + })); + + // Persist entries — await so that the config is written before + // onDidChangeConnections fires, ensuring _reconcile creates the provider. + // Tunnel entries are filtered out by _storeConfiguredEntries automatically. + await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); + + this._onDidChangeConnections.fire(); + + return { + address, + name: entry.name, + clientId: protocolClient.clientId, + defaultDirectory: protocolClient.defaultDirectory, + status: RemoteAgentHostConnectionStatus.Connected, + }; + } + + async removeRemoteAgentHost(address: string): Promise { + const normalized = normalizeRemoteAgentHostAddress(address); + // This setting is only used in the sessions app (user scope), so we + // don't need to inspect per-scope values like _upsertConfiguredEntry does. + const entries = this._getConfiguredEntries().filter( + e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) !== normalized + ); + await this._storeConfiguredEntries(entries); + + // Eagerly clear in-memory state so the UI updates immediately + // (the config change listener will reconcile, but this is instant). + this._names.delete(normalized); + this._tokens.delete(normalized); + this._registeredEntries.delete(normalized); + this._cancelReconnect(normalized); + this._reconnectAttempts.delete(normalized); + this._removeConnection(normalized); + } + + private _removeConnection(address: string): void { + const entry = this._entries.get(address); + if (entry) { + this._entries.delete(address); + entry.store.dispose(); + this._rejectPendingConnectionWait(address, new Error(`Connection closed: ${address}`)); + this._onDidChangeConnections.fire(); + } + } + + private _reconcileConnections(): void { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + // Disconnect all when disabled + for (const address of [...this._entries.keys()]) { + this._cancelReconnect(address); + this._removeConnection(address); + } + this._names.clear(); + this._tokens.clear(); + this._reconnectAttempts.clear(); + return; + } + + const rawEntries = (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); + const entriesWithAddress = rawEntries.map(e => ({ entry: e, address: normalizeRemoteAgentHostAddress(getEntryAddress(e)) })); + const desired = new Set(entriesWithAddress.map(e => e.address)); + + this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`); + + // Update name map and detect name changes for existing connections + let namesChanged = false; + const oldNames = new Map(this._names); + this._names.clear(); + this._tokens.clear(); + for (const { entry, address } of entriesWithAddress) { + this._names.set(address, entry.name); + this._tokens.set(address, entry.connectionToken); + if (this._entries.has(address) && oldNames.get(address) !== entry.name) { + namesChanged = true; + } + } + + // Remove connections no longer in the setting + for (const address of [...this._entries.keys()]) { + if (!desired.has(address)) { + this._logService.info(`[RemoteAgentHost] Disconnecting from ${address}`); + this._cancelReconnect(address); + this._reconnectAttempts.delete(address); + this._removeConnection(address); + } + } + + // Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService, + // and skip tunnel entries — those are handled by ITunnelAgentHostService) + for (const { entry, address } of entriesWithAddress) { + if (!this._entries.has(address) && entry.connection.type === RemoteAgentHostEntryType.WebSocket) { + this._connectTo(address, entry.connectionToken); + } + } + + // If only names changed (no add/remove), notify so the UI updates + if (namesChanged) { + this._onDidChangeConnections.fire(); + } + } + + private _connectTo(address: string, connectionToken?: string): void { + // Dispose any existing entry for this address before creating a new one + // to avoid leaking disposables on reconnect. + const existingEntry = this._entries.get(address); + if (existingEntry) { + this._entries.delete(address); + existingEntry.store.dispose(); + } + + const store = new DisposableStore(); + const transport = store.add(new WebSocketClientTransport(address, connectionToken)); + const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport)); + const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting }; + this._entries.set(address, entry); + + // Guard against stale callbacks: only act if the + // current entry for this address is still the one we created. + const isCurrentEntry = () => this._entries.get(address) === entry; + + store.add(client.onDidClose(() => { + if (!isCurrentEntry()) { + return; + } + this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`); + entry.connected = false; + entry.status = RemoteAgentHostConnectionStatus.Disconnected; + this._onDidChangeConnections.fire(); + // Schedule reconnect if the address is still configured + this._scheduleReconnect(address, connectionToken); + })); + + this._logService.info(`[RemoteAgentHost] Connecting to ${address}`); + this._onDidChangeConnections.fire(); + client.connect().then(() => { + if (store.isDisposed) { + return; // removed before connect resolved + } + this._logService.info(`[RemoteAgentHost] Connected to ${address}`); + entry.connected = true; + entry.status = RemoteAgentHostConnectionStatus.Connected; + this._reconnectAttempts.delete(address); + this._resolvePendingConnectionWait(address); + this._onDidChangeConnections.fire(); + }).catch((err: unknown) => { + if (!isCurrentEntry()) { + return; + } + this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}. Verify address and connectionToken`, err); + entry.status = RemoteAgentHostConnectionStatus.Disconnected; + // Clean up the failed entry + this._entries.delete(address); + entry.store.dispose(); + this._rejectPendingConnectionWait(address, err); + this._onDidChangeConnections.fire(); + // Schedule reconnect if the address is still configured + this._scheduleReconnect(address, connectionToken); + }); + } + + /** + * Schedule a reconnect attempt with exponential backoff. + * Only reconnects if the address is still in the configured entries. + */ + private _scheduleReconnect(address: string, connectionToken?: string): void { + // Don't reconnect if the address was removed from settings + if (!this._isAddressConfigured(address)) { + this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: no longer configured`); + return; + } + + const attempt = (this._reconnectAttempts.get(address) ?? 0) + 1; + this._reconnectAttempts.set(address, attempt); + const delay = Math.min( + RemoteAgentHostService.ReconnectInitialDelay * Math.pow(2, attempt - 1), + RemoteAgentHostService.ReconnectMaxDelay, + ); + + this._logService.info(`[RemoteAgentHost] Scheduling reconnect to ${address} in ${delay}ms (attempt ${attempt})`); + + this._cancelReconnect(address); + const timeout = setTimeout(() => { + this._reconnectTimeouts.delete(address); + if (this._isAddressConfigured(address)) { + this._connectTo(address, connectionToken ?? this._tokens.get(address)); + } + }, delay); + this._reconnectTimeouts.set(address, timeout); + } + + /** Cancel a pending reconnect timeout for the given address. */ + private _cancelReconnect(address: string): void { + const timeout = this._reconnectTimeouts.get(address); + if (timeout !== undefined) { + clearTimeout(timeout); + this._reconnectTimeouts.delete(address); + } + } + + /** Check whether the given normalized address is still in the configured entries. */ + private _isAddressConfigured(address: string): boolean { + const entries = this._getConfiguredEntries(); + return entries.some(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === address); + } + + private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined { + return this.connections.find(connection => connection.address === address && connection.status === RemoteAgentHostConnectionStatus.Connected); + } + + private _getConfiguredEntries(): IRemoteAgentHostEntry[] { + return (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); + } + + private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] { + // Read from the same scope we'll write to, so we don't accidentally + // merge entries from an overriding scope (e.g. workspace) into the + // user scope and then lose them on the next read. + const target = this._getConfigurationTarget(); + const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); + let configuredRaw: readonly IRawRemoteAgentHostEntry[]; + switch (target) { + case ConfigurationTarget.USER_LOCAL: + configuredRaw = inspected.userLocalValue ?? []; + break; + case ConfigurationTarget.USER_REMOTE: + configuredRaw = inspected.userRemoteValue ?? []; + break; + default: + configuredRaw = inspected.userValue ?? []; + break; + } + + const configuredEntries = configuredRaw.map(rawEntryToEntry).filter((e): e is IRemoteAgentHostEntry => e !== undefined); + const normalizedAddress = normalizeRemoteAgentHostAddress(getEntryAddress(entry)); + const existingIndex = configuredEntries.findIndex(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalizedAddress); + if (existingIndex === -1) { + return [...configuredEntries, entry]; + } + + return configuredEntries.map((e, index) => index === existingIndex ? entry : e); + } + + private _getConfigurationTarget(): ConfigurationTarget { + const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); + if (inspected.userLocalValue !== undefined) { + return ConfigurationTarget.USER_LOCAL; + } + if (inspected.userRemoteValue !== undefined) { + return ConfigurationTarget.USER_REMOTE; + } + if (inspected.userValue !== undefined) { + return ConfigurationTarget.USER; + } + return ConfigurationTarget.USER; + } + + private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise { + const raw = entries.map(entryToRawEntry).filter(isDefined); + await this._configurationService.updateValue(RemoteAgentHostsSettingId, raw, this._getConfigurationTarget()); + } + + private _getOrCreateConnectionWait(address: string): DeferredPromise { + let wait = this._pendingConnectionWaits.get(address); + if (wait) { + return wait; + } + + // If the connection is already available (fast connect resolved before + // the caller called us), return an immediately-completed wait. + const existingConnection = this._getConnectionInfo(address); + if (existingConnection) { + const immediateWait = new DeferredPromise(); + immediateWait.complete(existingConnection); + return immediateWait; + } + + wait = new DeferredPromise(); + this._pendingConnectionWaits.set(address, wait); + return wait; + } + + private _resolvePendingConnectionWait(address: string): void { + const wait = this._pendingConnectionWaits.get(address); + const connection = this._getConnectionInfo(address); + if (!wait || !connection) { + return; + } + + this._pendingConnectionWaits.delete(address); + void wait.complete(connection); + } + + private _rejectPendingConnectionWait(address: string, err: unknown): void { + const wait = this._pendingConnectionWaits.get(address); + if (!wait) { + return; + } + + this._pendingConnectionWaits.delete(address); + void wait.error(err); + } + + override dispose(): void { + for (const timeout of this._reconnectTimeouts.values()) { + clearTimeout(timeout); + } + this._reconnectTimeouts.clear(); + this._reconnectAttempts.clear(); + for (const [address, wait] of this._pendingConnectionWaits) { + void wait.error(new Error(`Remote agent host service disposed before connecting to ${address}`)); + } + this._pendingConnectionWaits.clear(); + for (const entry of this._entries.values()) { + entry.store.dispose(); + } + this._entries.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts new file mode 100644 index 0000000000000..556e6bd342cb5 --- /dev/null +++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// WebSocket client transport for connecting to remote agent host processes. +// Uses plain JSON serialization — URIs are string-typed in the protocol. + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { connectionTokenQueryName } from '../../../base/common/network.js'; +import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { IClientTransport } from '../common/state/sessionTransport.js'; +import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; + +// ---- Client transport ------------------------------------------------------- + +/** + * A WebSocket client transport that connects to a remote agent host server. + * Uses the native browser WebSocket API (available in Electron renderer). + * Implements {@link IClientTransport} with JSON serialization and URI revival. + */ +export class WebSocketClientTransport extends Disposable implements IClientTransport { + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + private readonly _onOpen = this._register(new Emitter()); + readonly onOpen = this._onOpen.event; + + private _ws: WebSocket | undefined; + private _malformedFrames = 0; + + get isOpen(): boolean { + return this._ws?.readyState === WebSocket.OPEN; + } + + constructor( + private readonly _address: string, + private readonly _connectionToken?: string, + ) { + super(); + } + + /** + * Initiate the WebSocket connection. Resolves when the connection + * is open, or rejects on error/timeout. + */ + connect(): Promise { + return new Promise((resolve, reject) => { + if (this._store.isDisposed) { + reject(new Error('Transport is disposed')); + return; + } + + let url = this._address.startsWith('ws://') || this._address.startsWith('wss://') + ? this._address + : `ws://${this._address}`; + + if (this._connectionToken) { + const separator = url.includes('?') ? '&' : '?'; + url += `${separator}${connectionTokenQueryName}=${encodeURIComponent(this._connectionToken)}`; + } + + const ws = new WebSocket(url); + this._ws = ws; + + const onOpen = () => { + cleanup(); + this._onOpen.fire(); + resolve(); + }; + + const onError = () => { + cleanup(); + reject(new Error(`WebSocket connection failed: ${this._address}`)); + }; + + const onClose = () => { + cleanup(); + reject(new Error(`WebSocket closed before connection was established: ${this._address}`)); + }; + + const cleanup = () => { + ws.removeEventListener('open', onOpen); + ws.removeEventListener('error', onError); + ws.removeEventListener('close', onClose); + }; + + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + ws.addEventListener('close', onClose); + + // Wire up long-lived listeners after connection + ws.addEventListener('message', (event: MessageEvent) => { + if (typeof event.data !== 'string') { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const dataType = event.data instanceof ArrayBuffer ? 'ArrayBuffer' : event.data instanceof Blob ? 'Blob' : typeof event.data; + const byteLen = event.data instanceof ArrayBuffer ? event.data.byteLength : event.data instanceof Blob ? event.data.size : 0; + console.warn( + `[WebSocketClientTransport] Non-string frame #${this._malformedFrames} (type=${dataType}, bytes=${byteLen})` + ); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { + console.warn( + `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` + ); + this._ws?.close(4002, 'malformed-frames'); + } + return; + } + const text = event.data; + let message: IProtocolMessage; + try { + message = JSON.parse(text) as IProtocolMessage; + } catch (err) { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const preview = text.length > 80 ? text.slice(0, 80) + '…' : text; + console.warn( + `[WebSocketClientTransport] Malformed frame #${this._malformedFrames} (len=${text.length}): ${preview}`, + err instanceof Error ? err.message : String(err) + ); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { + console.warn( + `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.` + ); + this._ws?.close(4002, 'malformed-frames'); + } + return; + } + this._onMessage.fire(message); + }); + + ws.addEventListener('close', () => { + this._onClose.fire(); + }); + + ws.addEventListener('error', () => { + // Error always precedes close - closing is handled in the close handler. + this._onClose.fire(); + }); + }); + } + + send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + if (this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify(message)); + } + } + + override dispose(): void { + this._ws?.close(); + super.dispose(); + } +} From cf8c57c90346d2591f4e4a276e04525775c29ceb Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 11:39:06 -0700 Subject: [PATCH 04/14] Remove unrelated changes and PWA support from mobile PR - Revert copilot package-lock.json to main (unrelated npm artifact) - Revert agentHost remoteAgentHostProtocolClient.ts (diffs field not mobile-related) - Revert agentHost sessionTransport.ts (protocol cleanup not mobile-related) - Remove theme-color meta element creation and dynamic updates (PWA) - Remove apple-touch-startup-image link injection (PWA) - Remove PWA & Viewport section from MOBILE.md - Keep viewport-fit=cover as it is needed for safe-area-inset CSS on mobile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/package-lock.json | 7 ++++++- src/vs/sessions/MOBILE.md | 7 ------- src/vs/sessions/browser/workbench.ts | 27 +-------------------------- 3 files changed, 7 insertions(+), 34 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index aa768f0ebc26c..e7b122547ef81 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -11515,6 +11515,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13730,6 +13731,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14591,7 +14593,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "node_modules/js-yaml": { "version": "4.1.1", @@ -15715,6 +15718,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -18755,6 +18759,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md index aff0afde7d27c..2ff7e869a7c91 100644 --- a/src/vs/sessions/MOBILE.md +++ b/src/vs/sessions/MOBILE.md @@ -141,13 +141,6 @@ The workbench toggles CSS classes (`phone-layout`, `mobile-layout`) on `layout() | `src/vs/sessions/browser/parts/media/sidebarPart.css` | Sidebar drawer overlay CSS: 85% width, z-index 250, slide-in animation, backdrop. | | `src/vs/sessions/browser/media/style.css` | Mobile overscroll containment, 44px touch targets, quick pick bottom sheets, context menu action sheets, dialog sizing, notification positioning, hover card suppression, editor modal full-screen. | -### PWA & Viewport - -| File | Purpose | -|------|---------| -| `src/vs/code/browser/workbench/workbench.html` | `viewport-fit=cover` meta tag, `theme-color` meta tag. | -| `resources/server/manifest.json` | PWA manifest: `background_color`, `theme_color`, `orientation`. | - ## Remaining Work - **Session title sync**: MobileTopBar shows hardcoded "New Session" — needs to subscribe to `sessionsManagementService.activeSession` and update title when session changes. diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 8915afce7683c..44d7448efb499 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -214,8 +214,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private _mainContainerDimension!: IDimension; get mainContainerDimension(): IDimension { return this._mainContainerDimension; } - private readonly _themeColorMeta: HTMLMetaElement; - get activeContainerDimension(): IDimension { return this.getContainerDimension(this.activeContainer); } @@ -307,14 +305,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic ) { super(); - // Cache the theme-color meta element for dynamic updates - const themeColorMeta = mainWindow.document.createElement('meta'); - themeColorMeta.name = 'theme-color'; - themeColorMeta.content = '#1e1e1e'; - mainWindow.document.head.appendChild(themeColorMeta); - this._themeColorMeta = themeColorMeta; - - // Sessions-scoped mobile/PWA viewport tweaks. These are applied here + // Sessions-scoped mobile viewport tweaks. These are applied here // (rather than in the shared workbench.html) so that the regular // code-web workbench — which does not handle safe-area insets — is // not affected on notched mobile devices. @@ -322,12 +313,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; } - if (!mainWindow.document.querySelector('link[rel="apple-touch-startup-image"]')) { - const startupImage = mainWindow.document.createElement('link'); - startupImage.rel = 'apple-touch-startup-image'; - startupImage.href = `${mainWindow.document.baseURI}favicon.ico`; - mainWindow.document.head.appendChild(startupImage); - } // Perf: measure workbench startup time mark('code/willStartWorkbench'); @@ -1289,16 +1274,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Emit as event this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); - - // Update mobile status bar theme color to match current theme - this.updateThemeColor(); - } - - private updateThemeColor(): void { - const bgColor = mainWindow.getComputedStyle(this.mainContainer).backgroundColor; - if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)') { - this._themeColorMeta.content = bgColor; - } } private layoutMobileSidebar(): void { From 5e2ca6854e8a1bc87a5295880006852ceb670834 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 13:44:23 -0700 Subject: [PATCH 05/14] Fix mobile chat welcome page CSS selectors to match actual DOM classes The mobile phone-layout CSS targeted non-existent class names (chat-full-welcome*) instead of the actual DOM classes used by newChatViewPane.ts and newChatInput.ts. This caused the welcome page to render without the Copilot logo, centering, or bottom-pinned input on phone viewports. Also fixes the logo SVG path and adds dark/light theme support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/mobile/mobileChatShell.css | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index dfcbc6f02d0f7..9f668fa35e4d5 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -256,14 +256,14 @@ /* ---- Phone Layout: Chat Welcome Page ---- */ /* Make the welcome page a flex column that fills the chat area */ -.agent-sessions-workbench.phone-layout .chat-full-welcome { +.agent-sessions-workbench.phone-layout .new-chat-widget-container { display: flex !important; flex-direction: column !important; height: 100% !important; padding: 8px 8px 0 8px !important; } -.agent-sessions-workbench.phone-layout .chat-full-welcome-content { +.agent-sessions-workbench.phone-layout .new-chat-widget-content { display: flex !important; flex-direction: column !important; flex: 1 !important; @@ -273,7 +273,7 @@ } /* Workspace picker centered vertically with icon above */ -.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers-container { +.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container { flex: 1 !important; display: flex !important; flex-direction: column !important; @@ -283,20 +283,25 @@ } /* Show the sessions logo above the workspace picker — same asset as the auth page */ -.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers-container::before { +.agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { content: ''; display: block; width: 64px; height: 64px; margin-bottom: 16px; - background-image: url('../../../../sessions/contrib/welcome/browser/media/sessions-logo-light.svg'); + background-image: url('../../media/sessions-logo-light.svg'); background-size: contain; background-repeat: no-repeat; background-position: center; } +.vs .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before, +.hc-light .agent-sessions-workbench.phone-layout .new-session-workspace-picker-container::before { + background-image: url('../../media/sessions-logo-dark.svg'); +} + /* Center the picker text */ -.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers { +.agent-sessions-workbench.phone-layout .session-workspace-picker { display: flex !important; flex-direction: column !important; align-items: center !important; @@ -304,13 +309,13 @@ font-size: 16px !important; } -.agent-sessions-workbench.phone-layout .chat-full-welcome-pickers-label { +.agent-sessions-workbench.phone-layout .session-workspace-picker-label { font-size: 18px !important; opacity: 0.6; } /* Input slot pinned to the bottom */ -.agent-sessions-workbench.phone-layout .chat-full-welcome-inputSlot { +.agent-sessions-workbench.phone-layout .new-chat-input-container { flex-shrink: 0 !important; padding: 0 0 8px 0 !important; max-width: 100% !important; @@ -323,7 +328,7 @@ } /* Hide the local mode bar (Copilot CLI / Default Approvals / Branch) on phone */ -.agent-sessions-workbench.phone-layout .chat-full-welcome-local-mode { +.agent-sessions-workbench.phone-layout .new-chat-bottom-container { display: none !important; } From ffe7c95c3ec2c7f942857b9e5b7b620959295597 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 15:25:37 -0700 Subject: [PATCH 06/14] Address PR review comments: titlebar visibility, CSS docs, inline styles - Fix isVisible(TITLEBAR_PART) to return false on phone layout where the grid titlebar is hidden and replaced by MobileTopBar - Fix computeContainerOffset() to use MobileTopBar height on phone layout so overlays (quick picks, hovers) are positioned correctly - Add CSS comment block documenting the .mobile-layout vs .phone-layout class hierarchy and when to use each - Add code comment in MobileAuxiliaryBarPart explaining why inline style clearing is needed (parent sets them via JS, CSS can't override) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/media/style.css | 10 ++++++++++ .../browser/parts/mobile/mobileAuxiliaryBarPart.ts | 5 +++++ src/vs/sessions/browser/workbench.ts | 7 ++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 483f64e138a50..fe7289b86c1fb 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -651,6 +651,16 @@ /* Phone-layout rules for parts, sashes, max-width constraints, and grid background live in mobileChatShell.css — do not duplicate them here. */ +/* + * Mobile CSS class hierarchy: + * .mobile-layout — applied to phone AND tablet viewports (< 1024px). + * Use for touch-specific styles: overscroll containment, + * 44px touch targets, input zoom prevention, momentum scroll. + * .phone-layout — applied to phone viewports only (< 640px). + * Use for phone-specific layout overrides: bottom-sheet + * quick picks, overlay panels, full-width context menus. + */ + /* ---- Mobile Layout: Overscroll Containment ---- */ /* Prevent body rubber-band on iOS and Chrome pull-to-refresh on Android */ diff --git a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts index 37118ab8d2d2b..6e51113b5235b 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts @@ -20,6 +20,11 @@ export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { // Run base theme wiring (skips AuxiliaryBarPart's card-specific inline styles) AbstractPaneCompositePart.prototype.updateStyles.call(this); + // The parent AuxiliaryBarPart.updateStyles() sets inline styles for + // card-like appearance (--part-background, --part-border-color, backgroundColor). + // On mobile the auxiliary bar fills the full grid cell without card margins, + // so we clear any residual inline styles. This must be done in JS because + // inline styles have the highest CSS specificity. const container = this.getContainer(); if (container) { container.style.backgroundColor = ''; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 44d7448efb499..7b9ac3eef7188 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -241,6 +241,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (this.isVisible(Parts.TITLEBAR_PART, mainWindow)) { top = this.getPart(Parts.TITLEBAR_PART).maximumHeight; quickPickTop = top; + } else if (this.mobileTopBarElement) { + // On phone layout the MobileTopBar replaces the titlebar + top = this.mobileTopBarElement.offsetHeight; + quickPickTop = top; } return { top, quickPickTop }; @@ -1442,7 +1446,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic isVisible(part: Parts, targetWindow?: Window): boolean { switch (part) { case Parts.TITLEBAR_PART: - return true; // Always visible + // On phone layout the grid titlebar is hidden (replaced by MobileTopBar) + return this.layoutPolicy.viewportClass.get() !== 'phone'; case Parts.SIDEBAR_PART: return this.partVisibility.sidebar; case Parts.AUXILIARYBAR_PART: From 1d1804ba9bb63715e5cf9bf5d7429e25e15d0914 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 19:01:25 -0700 Subject: [PATCH 07/14] Address council review: fix mobile Part runtime transitions and disposal hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mobile Parts now dispatch at runtime (isPhoneLayout): phone uses mobile layout math, tablet/desktop delegates to super. Fixes 35px chat overlap after phone→landscape rotation across the 640px breakpoint. - Workbench calls updateStyles() on pane composite parts after viewport class transitions so card-chrome inline styles re-apply/clear correctly. - MobileNavigationStack: replace _suppressNextPop boolean with pending counter to handle concurrent popSilently calls and rapid back taps. - Drop redundant Part.prototype.layout.call in mobile Part overrides (AbstractPaneCompositePart.layout already cascades to Part.layout). - Virtual keyboard detection: use mainWindow.innerHeight as baseline instead of captured initialViewportHeight; threshold 150→100. - Sidebar drawer backdrop: managed via DisposableStore with addDisposableListener for its click handler. - MobileTopBar: switch raw addEventListener to addDisposableListener; register element removal before parent.prepend so exceptions still clean up. - Remove redundant matchMedia orientation listener; the window resize listener already handles orientation changes. --- src/vs/sessions/MOBILE.md | 7 +- .../sessions/browser/mobileNavigationStack.ts | 13 +++- .../parts/mobile/mobileAuxiliaryBarPart.ts | 33 ++++++--- .../browser/parts/mobile/mobileChatBarPart.ts | 27 +++++-- .../browser/parts/mobile/mobileLayout.ts | 26 +++++++ .../browser/parts/mobile/mobilePanelPart.ts | 22 ++++-- .../browser/parts/mobile/mobileSidebarPart.ts | 13 +++- .../browser/parts/mobile/mobileTopBar.ts | 19 +++-- src/vs/sessions/browser/workbench.ts | 74 +++++++++---------- 9 files changed, 153 insertions(+), 81 deletions(-) create mode 100644 src/vs/sessions/browser/parts/mobile/mobileLayout.ts diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md index 2ff7e869a7c91..2e67cde85d878 100644 --- a/src/vs/sessions/MOBILE.md +++ b/src/vs/sessions/MOBILE.md @@ -8,12 +8,15 @@ ### Mobile Part Subclasses -Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) remain unchanged. Each has a **mobile subclass** that extends it and overrides only `layout()` and/or `updateStyles()` to remove card margins, border insets, and inline theme styles. `AgenticPaneCompositePartService` conditionally instantiates the mobile or desktop variant at startup based on viewport width (`< 640px` → phone). +Desktop Parts (`ChatBarPart`, `SidebarPart`, `PanelPart`, `AuxiliaryBarPart`) remain unchanged. Each has a **mobile subclass** that extends it and overrides only `layout()` and/or `updateStyles()`. `AgenticPaneCompositePartService` conditionally instantiates the mobile or desktop variant at startup based on viewport width (`< 640px` → phone). + +Each mobile Part checks the current layout class (via `isPhoneLayout(layoutService)`) at every call. When the viewport is phone it applies mobile behavior (full-cell layout, no card chrome, no session-bar subtraction). When the viewport is tablet/desktop — which happens when a real phone rotates past the 640px breakpoint — it delegates to the desktop `super` implementation. This means a `Mobile*Part` instance is safe to keep through a viewport-class transition without producing wrong layout math. This means: - Desktop code has **zero** phone-layout checks — all mobile logic lives in mobile subclasses, `MobileTopBar`, and CSS. +- Phone-instantiated parts adapt correctly to rotation across the 640px breakpoint by delegating to `super`. -**Known limitation:** Part classes are chosen once at construction and never swapped at runtime. If the viewport changes class (e.g., device rotation from portrait to landscape), the original Part implementations remain. This is acceptable because real mobile devices don't switch between phone and desktop — the scenario only occurs in DevTools emulation. +After a viewport-class transition the workbench calls `updateStyles()` on each pane composite part so card-chrome inline styles get re-applied (desktop) or cleared (phone) for the new class. ### View & Action Gating diff --git a/src/vs/sessions/browser/mobileNavigationStack.ts b/src/vs/sessions/browser/mobileNavigationStack.ts index f03f5ae55cc08..020022bf65c1f 100644 --- a/src/vs/sessions/browser/mobileNavigationStack.ts +++ b/src/vs/sessions/browser/mobileNavigationStack.ts @@ -69,23 +69,28 @@ export class MobileNavigationStack extends Disposable { * Use this when a layer is closed by UI interaction (e.g., backdrop click) * so the history and stack stay in sync without recursing back into * close handlers. + * + * Concurrent silent pops are handled via a counter: each call increments + * {@link _pendingSilentPops} and the matching {@link _onPopState} decrements + * it, so rapid back-button taps or multiple overlay closes cannot leak + * suppression state across unrelated pops. */ popSilently(layer: MobileNavigationLayer): void { for (let i = this._stack.length - 1; i >= 0; i--) { if (this._stack[i].layer === layer) { this._stack.splice(i, 1); - this._suppressNextPop = true; + this._pendingSilentPops++; mainWindow.history.back(); return; } } } - private _suppressNextPop = false; + private _pendingSilentPops = 0; private _onPopState(e: PopStateEvent): void { - if (this._suppressNextPop) { - this._suppressNextPop = false; + if (this._pendingSilentPops > 0) { + this._pendingSilentPops--; return; } diff --git a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts index 6e51113b5235b..fa402bc65e1f4 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts @@ -4,27 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { Part } from '../../../../workbench/browser/part.js'; import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; import { AuxiliaryBarPart } from '../auxiliaryBarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; /** * Mobile variant of AuxiliaryBarPart. * * On phone-sized viewports the auxiliary bar fills the full grid cell - * without card margins or border insets. + * without card margins or border insets. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. */ export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { override updateStyles(): void { - // Run base theme wiring (skips AuxiliaryBarPart's card-specific inline styles) - AbstractPaneCompositePart.prototype.updateStyles.call(this); - - // The parent AuxiliaryBarPart.updateStyles() sets inline styles for - // card-like appearance (--part-background, --part-border-color, backgroundColor). - // On mobile the auxiliary bar fills the full grid cell without card margins, - // so we clear any residual inline styles. This must be done in JS because - // inline styles have the highest CSS specificity. + // Always run the desktop implementation first so inline card styles + // are set on tablet/desktop transitions. In phone mode we then + // clear them so CSS can take over (inline styles have the highest + // specificity). + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + const container = this.getContainer(); if (container) { container.style.backgroundColor = ''; @@ -34,12 +38,17 @@ export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { } override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { return; } - // Full dimensions — no card margins or border subtraction + // Full dimensions — no card margins or border subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout. AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); - Part.prototype.layout.call(this, width, height, top, left); } } diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts index e78877a0c4110..f4f82dcd031cb 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts @@ -4,21 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { Part } from '../../../../workbench/browser/part.js'; import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; import { ChatBarPart } from '../chatBarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; /** * Mobile variant of ChatBarPart. * * On phone-sized viewports the chat bar fills the full grid cell without - * card margins, border insets, or session-bar height adjustments. + * card margins, border insets, or session-bar height adjustments. When + * the viewport transitions to tablet/desktop (e.g., device rotation + * crossing the phone breakpoint) this delegates to the desktop + * implementation so layout math stays correct. */ export class MobileChatBarPart extends ChatBarPart { override updateStyles(): void { - // Run base theme wiring (skips ChatBarPart's card-specific inline styles) - AbstractPaneCompositePart.prototype.updateStyles.call(this); + // Always run the desktop implementation first so inline styles are + // set on tablet/desktop transitions. In phone mode we then clear + // the card-specific inline styles so CSS can take over. + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } const container = this.getContainer(); if (container) { @@ -30,14 +39,20 @@ export class MobileChatBarPart extends ChatBarPart { } override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + if (!this.layoutService.isVisible(Parts.CHATBAR_PART)) { return; } this._lastLayout = { width, height, top, left }; - // Full dimensions — no card margins or session-bar subtraction + // Full dimensions — no card margins or session-bar subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout so + // there is no need to invoke Part.prototype.layout separately. AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); - Part.prototype.layout.call(this, width, height, top, left); } } diff --git a/src/vs/sessions/browser/parts/mobile/mobileLayout.ts b/src/vs/sessions/browser/parts/mobile/mobileLayout.ts new file mode 100644 index 0000000000000..3d7ca98d99a25 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileLayout.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; + +/** + * CSS class applied to the sessions workbench main container when the + * viewport is classified as phone. Must stay in sync with + * `LayoutClasses.PHONE_LAYOUT` in `workbench.ts`. + */ +const PHONE_LAYOUT_CLASS = 'phone-layout'; + +/** + * Returns true when the sessions workbench currently has the phone + * layout class on its main container. + * + * Mobile Part subclasses are chosen once at construction time, but the + * viewport class can change at runtime (e.g., device rotation crossing + * the phone breakpoint). Parts use this to decide whether to apply + * mobile-specific layout math or defer to the desktop implementation. + */ +export function isPhoneLayout(layoutService: IWorkbenchLayoutService): boolean { + return layoutService.mainContainer.classList.contains(PHONE_LAYOUT_CLASS); +} diff --git a/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts index fdc8a07854a89..2891360e96729 100644 --- a/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts @@ -4,21 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { Part } from '../../../../workbench/browser/part.js'; import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; import { PanelPart } from '../panelPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; /** * Mobile variant of PanelPart. * * On phone-sized viewports the panel fills the full grid cell - * without card margins or border insets. + * without card margins or border insets. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. */ export class MobilePanelPart extends PanelPart { override updateStyles(): void { - // Run base theme wiring (skips PanelPart's card-specific inline styles) - AbstractPaneCompositePart.prototype.updateStyles.call(this); + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } const container = this.getContainer(); if (container) { @@ -29,12 +34,17 @@ export class MobilePanelPart extends PanelPart { } override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + if (!this.layoutService.isVisible(Parts.PANEL_PART)) { return; } - // Full dimensions — no card margins or border subtraction + // Full dimensions — no card margins or border subtraction. + // AbstractPaneCompositePart.layout internally calls Part.layout. AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); - Part.prototype.layout.call(this, width, height, top, left); } } diff --git a/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts index 119ebc1532cd6..f062ddfd2f4f8 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts @@ -5,17 +5,26 @@ import { AbstractPaneCompositePart } from '../../../../workbench/browser/parts/paneCompositePart.js'; import { SidebarPart } from '../sidebarPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; /** * Mobile variant of SidebarPart. * * On phone-sized viewports the sidebar skips card-specific inline styles - * so that CSS-only theming takes over. + * so that CSS-only theming takes over. On tablet/desktop it falls back + * to the desktop behavior so runtime viewport transitions keep working. */ export class MobileSidebarPart extends SidebarPart { override updateStyles(): void { - // Run base theme wiring (skips SidebarPart's card / title-area inline styles) + // Run base theme wiring; this also cascades to AbstractPaneCompositePart. + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + // Skip SidebarPart's card / title-area inline styles on phone. AbstractPaneCompositePart.prototype.updateStyles.call(this); const container = this.getContainer(); diff --git a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts index 78dd4e6c2b21f..c42dd036ebe6d 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import './mobileChatShell.css'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { $, append } from '../../../../base/browser/dom.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -37,6 +37,11 @@ export class MobileTopBar extends Disposable { this.element = document.createElement('div'); this.element.className = 'mobile-top-bar'; + + // Register DOM removal before appending so that any exception + // between this point and the end of the constructor still cleans + // up the element via disposal. + this._register(toDisposable(() => this.element.remove())); parent.prepend(this.element); // Hamburger button @@ -44,21 +49,19 @@ export class MobileTopBar extends Disposable { hamburger.setAttribute('aria-label', 'Open sessions'); const hamburgerIcon = append(hamburger, $('span')); hamburgerIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.menu)); - hamburger.addEventListener('click', () => this._onDidClickHamburger.fire()); + this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire())); // Session title this.sessionTitleElement = append(this.element, $('div.mobile-session-title')); - this.sessionTitleElement.textContent = 'New Session'; - this.sessionTitleElement.addEventListener('click', () => this._onDidClickTitle.fire()); + this.sessionTitleElement.textContent = localize('mobileTopBar.newSession', "New Session"); + this._register(addDisposableListener(this.sessionTitleElement, EventType.CLICK, () => this._onDidClickTitle.fire())); // New session button (+) const newSession = append(this.element, $('button.mobile-top-bar-button')); newSession.setAttribute('aria-label', 'New session'); const newSessionIcon = append(newSession, $('span')); newSessionIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.plus)); - newSession.addEventListener('click', () => this._onDidClickNewSession.fire()); - - this._register({ dispose: () => this.element.remove() }); + this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire())); } setTitle(title: string): void { diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 7b9ac3eef7188..5a4d3bcd8a2f8 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -7,7 +7,7 @@ import '../../workbench/browser/style.js'; import './media/style.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle, addDisposableListener, EventType } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; @@ -313,6 +313,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // (rather than in the shared workbench.html) so that the regular // code-web workbench — which does not handle safe-area insets — is // not affected on notched mobile devices. + // eslint-disable-next-line no-restricted-syntax const viewportMeta = mainWindow.document.querySelector('meta[name="viewport"]'); if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; @@ -427,54 +428,33 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic isMobileLayoutCtx.set(vc === 'phone' || vc === 'tablet'); })); - // Virtual keyboard detection via visualViewport API + // Virtual keyboard detection via visualViewport API. + // Use `window.innerHeight` (layout viewport) as the baseline + // rather than a captured initial height. Layout viewport + // updates on orientation change and split-screen resizes, so + // comparing against it avoids stale baselines on landscape + // launches, Android split-screen, and iOS URL-bar collapse. if (mainWindow.visualViewport) { const keyboardVisibleCtx = KeyboardVisibleContext.bindTo(contextKeyService); - let initialViewportHeight = mainWindow.visualViewport.height; + const KEYBOARD_HEIGHT_THRESHOLD_PX = 100; const onViewportResize = () => { const vp = mainWindow.visualViewport; if (!vp) { return; } - // Keyboard is considered visible when viewport shrinks by more than 150px - const heightDiff = initialViewportHeight - vp.height; - const isKeyboardUp = heightDiff > 150; - keyboardVisibleCtx.set(isKeyboardUp); - - // Update initial height if viewport grew (orientation change, not keyboard) - if (vp.height > initialViewportHeight) { - initialViewportHeight = vp.height; - } + const heightDiff = mainWindow.innerHeight - vp.height; + keyboardVisibleCtx.set(heightDiff > KEYBOARD_HEIGHT_THRESHOLD_PX); }; mainWindow.visualViewport.addEventListener('resize', onViewportResize); this._register({ dispose: () => mainWindow.visualViewport?.removeEventListener('resize', onViewportResize) }); } - // Orientation change: re-evaluate viewport class and re-layout - const orientationMediaQuery = mainWindow.matchMedia('(orientation: portrait)'); - let orientationRelayoutHandle: number | undefined; - const onOrientationChange = () => { - // Small delay to let the viewport settle after orientation change - if (orientationRelayoutHandle !== undefined) { - mainWindow.clearTimeout(orientationRelayoutHandle); - } - orientationRelayoutHandle = mainWindow.setTimeout(() => { - orientationRelayoutHandle = undefined; - this.layout(); - }, 100); - }; - orientationMediaQuery.addEventListener('change', onOrientationChange); - this._register({ - dispose: () => { - orientationMediaQuery.removeEventListener('change', onOrientationChange); - if (orientationRelayoutHandle !== undefined) { - mainWindow.clearTimeout(orientationRelayoutHandle); - orientationRelayoutHandle = undefined; - } - } - }); + // Orientation changes produce a window `resize` event which + // is already handled by `registerLayoutListeners()`. No + // separate matchMedia listener is needed — the previous + // implementation caused a redundant second layout. // Register Listeners this.registerListeners(lifecycleService, storageService, configurationService, hostService, dialogService); @@ -725,6 +705,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } private sidebarDrawerBackdrop: HTMLElement | undefined; + private readonly sidebarDrawerBackdropDisposables = this._register(new DisposableStore()); private toggleMobileSidebarDrawer(): void { const isOpen = this.partVisibility.sidebar; @@ -736,11 +717,14 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } private openMobileSidebarDrawer(): void { - // Show backdrop + // Show backdrop — created fresh each open so its click listener is + // tracked by a DisposableStore and cleaned up on close. if (!this.sidebarDrawerBackdrop) { - this.sidebarDrawerBackdrop = document.createElement('div'); - this.sidebarDrawerBackdrop.className = 'mobile-sidebar-backdrop'; - this.sidebarDrawerBackdrop.addEventListener('click', () => this.closeMobileSidebarDrawer()); + const backdrop = document.createElement('div'); + backdrop.className = 'mobile-sidebar-backdrop'; + this.sidebarDrawerBackdropDisposables.add(addDisposableListener(backdrop, EventType.CLICK, () => this.closeMobileSidebarDrawer())); + this.sidebarDrawerBackdropDisposables.add(toDisposable(() => backdrop.remove())); + this.sidebarDrawerBackdrop = backdrop; } this.mainContainer.appendChild(this.sidebarDrawerBackdrop); @@ -758,8 +742,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } private closeMobileSidebarDrawer(): void { - // Remove backdrop - this.sidebarDrawerBackdrop?.remove(); + // Remove backdrop and dispose its listener. + this.sidebarDrawerBackdropDisposables.clear(); + this.sidebarDrawerBackdrop = undefined; // Hide sidebar in grid this.setSideBarHidden(true); @@ -1261,6 +1246,13 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.setPanelHidden(!defaults.panel); } } + + // Re-run updateStyles() on pane composite parts so that + // mobile Part subclasses can re-apply or clear card-chrome + // inline styles based on the new `.phone-layout` class. + for (const partId of [Parts.CHATBAR_PART, Parts.SIDEBAR_PART, Parts.AUXILIARYBAR_PART, Parts.PANEL_PART]) { + this.parts.get(partId)?.updateStyles(); + } } this._previousViewportClass = currentClass; From 973e3fa8404befbfbc6967d56ba82c657c69a724 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 19:06:13 -0700 Subject: [PATCH 08/14] Use head.getElementsByTagName instead of querySelector for viewport meta --- src/vs/sessions/browser/workbench.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 5a4d3bcd8a2f8..6938c4bce147b 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -309,14 +309,18 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic ) { super(); - // Sessions-scoped mobile viewport tweaks. These are applied here - // (rather than in the shared workbench.html) so that the regular - // code-web workbench — which does not handle safe-area insets — is - // not affected on notched mobile devices. + // Sessions-scoped mobile viewport tweaks. Applied here rather than + // in the shared `workbench.html` so that the regular code-web + // workbench — which does not handle safe-area insets — is not + // affected on notched mobile devices. The viewport meta tag lives + // in `workbench.html` and cannot be constructed via dom.ts h(), + // so iterating pre-existing head children is the correct approach. // eslint-disable-next-line no-restricted-syntax - const viewportMeta = mainWindow.document.querySelector('meta[name="viewport"]'); - if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { - viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; + for (const meta of mainWindow.document.head.getElementsByTagName('meta')) { + if (meta.name === 'viewport' && !meta.content.includes('viewport-fit=')) { + meta.content = `${meta.content}, viewport-fit=cover`; + break; + } } // Perf: measure workbench startup time From a053290be33d3d3baa056b53ce90a027e7f4ad0e Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 21:02:03 -0700 Subject: [PATCH 09/14] Scope mobile layout to phone only (remove .mobile-layout class) --- .../browser/remoteAgentHostServiceImpl.ts | 12 +++- .../browser/webSocketClientTransport.ts | 10 ++-- src/vs/sessions/MOBILE.md | 37 ++++++------ src/vs/sessions/browser/layoutPolicy.ts | 7 +-- src/vs/sessions/browser/media/style.css | 56 +++++++++---------- .../browser/parts/media/sidebarPart.css | 2 +- .../browser/parts/mobile/mobileChatShell.css | 4 +- .../browser/parts/mobile/mobileTopBar.ts | 1 + src/vs/sessions/browser/workbench.ts | 38 ++++++------- src/vs/sessions/common/contextkeys.ts | 3 +- .../changes/browser/changes.contribution.ts | 4 +- .../chat/browser/openInVSCode.contribution.ts | 4 +- .../openInVSCode.contribution.ts | 4 +- .../browser/codeReview.contributions.ts | 4 +- .../files/browser/files.contribution.ts | 6 +- .../contrib/logs/browser/logs.contribution.ts | 4 +- .../sessions/browser/media/sessionsList.css | 6 +- .../browser/sessionsTerminalContribution.ts | 4 +- 18 files changed, 104 insertions(+), 102 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts index f14d1b42b602c..3070a1744814d 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostServiceImpl.ts @@ -8,7 +8,7 @@ // and maintains connections, reconnecting as the setting changes. import { Emitter } from '../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -59,7 +59,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo private readonly _tokens = new Map(); /** * Stores the original {@link IRemoteAgentHostEntry} for connections - * registered via {@link addSSHConnection}. This is needed because + * registered via {@link addManagedConnection}. This is needed because * tunnel entries are not persisted to settings and therefore don't * appear in {@link configuredEntries}. */ @@ -206,7 +206,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return connection; } - async addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise { + async addManagedConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection, transportDisposable?: IDisposable): Promise { const address = getEntryAddress(entry); // Dispose any existing entry for this address to avoid leaking @@ -222,6 +222,12 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // Create a connection entry wrapping the pre-connected client const protocolClient = connection as RemoteAgentHostProtocolClient; store.add(protocolClient); + // Tear the underlying transport (e.g. SSH/tunnel relay) down with + // the entry. This is what makes "Remove Remote" actually close the + // shared-process tunnel and stop the remote agent host process. + if (transportDisposable) { + store.add(transportDisposable); + } const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected }; this._entries.set(address, connEntry); this._names.set(address, entry.name); diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts index 556e6bd342cb5..6d01fe3e41614 100644 --- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts +++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts @@ -9,7 +9,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { connectionTokenQueryName } from '../../../base/common/network.js'; -import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IClientTransport } from '../common/state/sessionTransport.js'; import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js'; @@ -22,7 +22,7 @@ import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from */ export class WebSocketClientTransport extends Disposable implements IClientTransport { - private readonly _onMessage = this._register(new Emitter()); + private readonly _onMessage = this._register(new Emitter()); readonly onMessage = this._onMessage.event; private readonly _onClose = this._register(new Emitter()); @@ -114,9 +114,9 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans return; } const text = event.data; - let message: IProtocolMessage; + let message: ProtocolMessage; try { - message = JSON.parse(text) as IProtocolMessage; + message = JSON.parse(text) as ProtocolMessage; } catch (err) { this._malformedFrames++; if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { @@ -148,7 +148,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans }); } - send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { if (this._ws?.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify(message)); } diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md index 2e67cde85d878..6ad57b7ca63fe 100644 --- a/src/vs/sessions/MOBILE.md +++ b/src/vs/sessions/MOBILE.md @@ -20,26 +20,28 @@ After a viewport-class transition the workbench calls `updateStyles()` on each p ### View & Action Gating -Views, menu items, and actions use `when` clauses with the `sessionsIsMobileLayout` context key to control visibility per viewport class. This follows a **default-deny** approach for mobile: +Views, menu items, and actions use `when` clauses with the `sessionsIsPhoneLayout` context key to control visibility in phone layout. This follows a **default-deny** approach for phone: -- **Desktop-only features** add `when: IsMobileLayoutContext.negate()` to their view descriptors and menu registrations. They simply don't appear on mobile. -- **Mobile-compatible features** (chat, sessions list) have no mobile gate — they render on all viewports. -- **Mobile-specific replacements** (when ready) register with `when: IsMobileLayoutContext` and live in separate files under `parts/mobile/contributions/`. +- **Desktop-only features** add `when: IsPhoneLayoutContext.negate()` to their view descriptors and menu registrations. They simply don't appear on phone. +- **Phone-compatible features** (chat, sessions list) have no phone gate — they render on all viewports. +- **Phone-specific replacements** (when ready) register with `when: IsPhoneLayoutContext` and live in separate files under `parts/mobile/contributions/`. + +Tablet and larger viewports currently fall back to the desktop layout; no separate tablet design exists yet. Two registrations can target the same slot with opposite `when` clauses, pointing to different view classes in different files — giving full file separation with no internal branching. #### Current Gating Status -| Feature | Mobile Status | Mechanism | +| Feature | Phone Status | Mechanism | |---------|--------------|-----------| | Sessions list (sidebar) | ✅ Compatible | No gate | | Chat views (ChatBar) | ✅ Compatible | No gate | -| Changes view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsMobileLayout` on view descriptor | -| Files view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsMobileLayout` on view descriptor | -| Logs view (Panel) | ❌ Gated | `when: !sessionsIsMobileLayout` on view descriptor | -| Terminal actions | ❌ Gated | `when: !sessionsIsMobileLayout` on menu item | -| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsMobileLayout` on menu item | -| Code review toolbar | ❌ Gated | `when: !sessionsIsMobileLayout` on menu item | +| Changes view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Files view (AuxiliaryBar) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Logs view (Panel) | ❌ Gated | `when: !sessionsIsPhoneLayout` on view descriptor | +| Terminal actions | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| "Open in VS Code" action | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | +| Code review toolbar | ❌ Gated | `when: !sessionsIsPhoneLayout` on menu item | | Customizations toolbar | ❌ Hidden | CSS `display: none` on phone | | Titlebar | ❌ Hidden | Grid `visible: false` + CSS + MobileTopBar replacement | @@ -72,18 +74,17 @@ On phone-sized viewports (`< 640px` width): `SessionsLayoutPolicy` classifies the viewport: - **phone**: `width < 640px` -- **tablet**: `640px ≤ width < 1024px` +- **tablet**: `640px ≤ width < 1024px` (treated as desktop; no phone-specific chrome) - **desktop**: `width ≥ 1024px` -The workbench toggles CSS classes (`phone-layout`, `mobile-layout`) on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation). MobileTopBar lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. +The workbench toggles the `phone-layout` CSS class on `layout()` and creates/destroys mobile components when the viewport class changes at runtime (e.g., DevTools device emulation, or a real phone rotating across the 640px breakpoint). MobileTopBar lifecycle is managed via a `DisposableStore` that is cleared on viewport transitions to prevent leaks. ### Context Keys | Key | Type | Purpose | |-----|------|---------| -| `sessionsViewportClass` | `string` | `'phone'`, `'tablet'`, or `'desktop'` | -| `sessionsIsMobileLayout` | `boolean` | `true` when phone or tablet | -| `sessionsKeyboardVisible` | `boolean` | `true` when virtual keyboard is visible | +| `sessionsIsPhoneLayout` | `boolean` | `true` when the viewport is phone (< 640px) | +| `sessionsKeyboardVisible` | `boolean` | `true` when the virtual keyboard is visible | ### Desktop → Mobile Component Mapping @@ -121,7 +122,7 @@ The workbench toggles CSS classes (`phone-layout`, `mobile-layout`) on `layout() |------|---------| | `src/vs/sessions/browser/layoutPolicy.ts` | `SessionsLayoutPolicy`: observable viewport classification (phone/tablet/desktop), platform flags (isIOS, isAndroid, isTouchDevice), part visibility and size defaults. | | `src/vs/sessions/browser/mobileNavigationStack.ts` | `MobileNavigationStack`: Android back button integration via `history.pushState` / `popstate`. Supports `push()`, `pop()`, and `clear()`. | -| `src/vs/sessions/common/contextkeys.ts` | Mobile context keys: `ViewportClassContext`, `IsMobileLayoutContext`, `KeyboardVisibleContext`. | +| `src/vs/sessions/common/contextkeys.ts` | Phone context keys: `IsPhoneLayoutContext`, `KeyboardVisibleContext`. | ### Part Instantiation @@ -147,7 +148,7 @@ The workbench toggles CSS classes (`phone-layout`, `mobile-layout`) on `layout() ## Remaining Work - **Session title sync**: MobileTopBar shows hardcoded "New Session" — needs to subscribe to `sessionsManagementService.activeSession` and update title when session changes. -- **Files & Terminal access**: Should become mobile-specific views gated with `when: IsMobileLayoutContext`. +- **Files & Terminal access**: Should become phone-specific views gated with `when: IsPhoneLayoutContext`. - **iOS keyboard handling**: Adjust layout when virtual keyboard appears (context key exists, but no layout response yet). - **Session list inline actions**: Make always-visible on touch devices (no hover-to-reveal). - **Customizations on mobile**: Currently hidden — needs a mobile-friendly alternative. diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts index 95da097c2f857..6ca7e21f6ed10 100644 --- a/src/vs/sessions/browser/layoutPolicy.ts +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -71,10 +71,9 @@ export class SessionsLayoutPolicy extends Disposable { /** Current viewport class derived from the most recent `update()` call. */ readonly viewportClass: IObservable = this._viewportClass; - /** `true` when the viewport class is `phone` or `tablet`. */ - readonly isMobileLayout: IObservable = derived(this, reader => { - const vc = this._viewportClass.read(reader); - return vc === 'phone' || vc === 'tablet'; + /** `true` when the viewport class is `phone`. */ + readonly isPhoneLayout: IObservable = derived(this, reader => { + return this._viewportClass.read(reader) === 'phone'; }); constructor() { diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index fe7289b86c1fb..7bf0601e7bcea 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -652,48 +652,44 @@ background live in mobileChatShell.css — do not duplicate them here. */ /* - * Mobile CSS class hierarchy: - * .mobile-layout — applied to phone AND tablet viewports (< 1024px). - * Use for touch-specific styles: overscroll containment, - * 44px touch targets, input zoom prevention, momentum scroll. - * .phone-layout — applied to phone viewports only (< 640px). - * Use for phone-specific layout overrides: bottom-sheet - * quick picks, overlay panels, full-width context menus. + * Phone layout (< 640px) styles. Currently the only mobile form factor + * supported by the sessions workbench; tablet/larger viewports fall back + * to the desktop layout. */ -/* ---- Mobile Layout: Overscroll Containment ---- */ +/* ---- Phone Layout: Overscroll Containment ---- */ /* Prevent body rubber-band on iOS and Chrome pull-to-refresh on Android */ -.agent-sessions-workbench.mobile-layout .monaco-scrollable-element > .scrollable-element { +.agent-sessions-workbench.phone-layout .monaco-scrollable-element > .scrollable-element { overscroll-behavior: contain; } -.agent-sessions-workbench.mobile-layout .interactive-session { +.agent-sessions-workbench.phone-layout .interactive-session { overscroll-behavior: contain; } -.agent-sessions-workbench.mobile-layout .monaco-list { +.agent-sessions-workbench.phone-layout .monaco-list { overscroll-behavior: contain; } -/* ---- Mobile Layout: Touch Target Sizing ---- */ +/* ---- Phone Layout: Touch Target Sizing ---- */ /* Ensure interactive elements meet 44px minimum touch target */ -.agent-sessions-workbench.mobile-layout .action-item > .action-label { +.agent-sessions-workbench.phone-layout .action-item > .action-label { min-height: 44px; min-width: 44px; } /* Touch action for tap responsiveness */ -.agent-sessions-workbench.mobile-layout .action-item, -.agent-sessions-workbench.mobile-layout button { +.agent-sessions-workbench.phone-layout .action-item, +.agent-sessions-workbench.phone-layout button { touch-action: manipulation; } /* Disable text selection callout on interactive elements */ -.agent-sessions-workbench.mobile-layout .action-item, -.agent-sessions-workbench.mobile-layout .monaco-toolbar, -.agent-sessions-workbench.mobile-layout .sidebar-footer { +.agent-sessions-workbench.phone-layout .action-item, +.agent-sessions-workbench.phone-layout .monaco-toolbar, +.agent-sessions-workbench.phone-layout .sidebar-footer { -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; @@ -780,15 +776,15 @@ border-radius: 12px; } -/* ---- Mobile Layout: Hover Cards ---- */ +/* ---- Phone Layout: Hover Cards ---- */ /* Disable delayed hover cards on touch devices — they never trigger */ -.agent-sessions-workbench.mobile-layout .monaco-hover { +.agent-sessions-workbench.phone-layout .monaco-hover { display: none !important; } /* Exception: keep hovers that are explicitly triggered (e.g., info buttons) */ -.agent-sessions-workbench.mobile-layout .monaco-hover.visible-on-mobile { +.agent-sessions-workbench.phone-layout .monaco-hover.visible-on-mobile { display: block !important; } @@ -828,22 +824,22 @@ padding-top: env(safe-area-inset-top); } -/* ---- Mobile Layout: Input Auto-Zoom Prevention ---- */ +/* ---- Phone Layout: Input Auto-Zoom Prevention ---- */ /* iOS Safari zooms in on input focus when font-size < 16px. - Force minimum 16px on all input elements in mobile layout. */ -.agent-sessions-workbench.mobile-layout input, -.agent-sessions-workbench.mobile-layout textarea, -.agent-sessions-workbench.mobile-layout .monaco-inputbox input, -.agent-sessions-workbench.mobile-layout .chat-input-container textarea { + Force minimum 16px on all input elements on phone. */ +.agent-sessions-workbench.phone-layout input, +.agent-sessions-workbench.phone-layout textarea, +.agent-sessions-workbench.phone-layout .monaco-inputbox input, +.agent-sessions-workbench.phone-layout .chat-input-container textarea { font-size: 16px !important; } -/* ---- Mobile Layout: Native Scroll Preservation ---- */ +/* ---- Phone Layout: Native Scroll Preservation ---- */ -/* Ensure chat content uses momentum scrolling on mobile. +/* Ensure chat content uses momentum scrolling on phone. The -webkit-overflow-scrolling property is needed for older iOS. */ -.agent-sessions-workbench.mobile-layout .interactive-session .monaco-scrollable-element { +.agent-sessions-workbench.phone-layout .interactive-session .monaco-scrollable-element { -webkit-overflow-scrolling: touch; } diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index 2774335cf422c..06b464e5d0737 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -117,7 +117,7 @@ } /* Increase sidebar footer action button height for touch */ -.agent-sessions-workbench.mobile-layout .part.sidebar > .sidebar-footer .sidebar-action-button { +.agent-sessions-workbench.phone-layout .part.sidebar > .sidebar-footer .sidebar-action-button { min-height: 44px; padding: 8px 12px; } diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 9f668fa35e4d5..9e65c868ed321 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -240,11 +240,11 @@ } /* Overscroll containment */ -.agent-sessions-workbench.mobile-layout .interactive-session { +.agent-sessions-workbench.phone-layout .interactive-session { overscroll-behavior: contain; } -.agent-sessions-workbench.mobile-layout .monaco-list { +.agent-sessions-workbench.phone-layout .monaco-list { overscroll-behavior: contain; } diff --git a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts index c42dd036ebe6d..26a1aed9642d9 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts @@ -9,6 +9,7 @@ import { $, addDisposableListener, append, EventType } from '../../../../base/br import { Emitter, Event } from '../../../../base/common/event.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; /** * Mobile top bar component — a simple DOM element prepended to the diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 6938c4bce147b..210ddfd42fa74 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -99,8 +99,7 @@ enum LayoutClasses { EXPERIMENTAL_SEND_BUTTON_GRADIENT = 'sessions-experimental-send-button-gradient', FULLSCREEN = 'fullscreen', MAXIMIZED = 'maximized', - PHONE_LAYOUT = 'phone-layout', - MOBILE_LAYOUT = 'mobile-layout' + PHONE_LAYOUT = 'phone-layout' } //#endregion @@ -309,19 +308,25 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic ) { super(); - // Sessions-scoped mobile viewport tweaks. Applied here rather than - // in the shared `workbench.html` so that the regular code-web - // workbench — which does not handle safe-area insets — is not - // affected on notched mobile devices. The viewport meta tag lives - // in `workbench.html` and cannot be constructed via dom.ts h(), - // so iterating pre-existing head children is the correct approach. + // Sessions-scoped mobile viewport tweaks. These are applied here + // (rather than in the shared workbench.html) so that the regular + // code-web workbench — which does not handle safe-area insets — is + // not affected on notched mobile devices. + // The viewport `` tag is injected by the shared workbench.html, + // so we cannot use dom.ts `h()` to create it. Look it up by tag name + // and filter by the `name` attribute to avoid a selector query. // eslint-disable-next-line no-restricted-syntax - for (const meta of mainWindow.document.head.getElementsByTagName('meta')) { - if (meta.name === 'viewport' && !meta.content.includes('viewport-fit=')) { - meta.content = `${meta.content}, viewport-fit=cover`; + const metaElements = mainWindow.document.head.getElementsByTagName('meta'); + let viewportMeta: HTMLMetaElement | undefined; + for (let i = 0; i < metaElements.length; i++) { + if (metaElements[i].name === 'viewport') { + viewportMeta = metaElements[i]; break; } } + if (viewportMeta && !viewportMeta.content.includes('viewport-fit=')) { + viewportMeta.content = `${viewportMeta.content}, viewport-fit=cover`; + } // Perf: measure workbench startup time mark('code/willStartWorkbench'); @@ -422,14 +427,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editorMaximizedContext.set(this.isEditorMaximized()); })); - // Mobile Layout Context Keys + // Phone Layout Context Key const contextKeyService = accessor.get(IContextKeyService); - const viewportClassCtx = ViewportClassContext.bindTo(contextKeyService); - const isMobileLayoutCtx = IsMobileLayoutContext.bindTo(contextKeyService); + const isPhoneLayoutCtx = IsPhoneLayoutContext.bindTo(contextKeyService); this._register(autorun(reader => { - const vc = this.layoutPolicy.viewportClass.read(reader); - viewportClassCtx.set(vc); - isMobileLayoutCtx.set(vc === 'phone' || vc === 'tablet'); + isPhoneLayoutCtx.set(this.layoutPolicy.viewportClass.read(reader) === 'phone'); })); // Virtual keyboard detection via visualViewport API. @@ -1209,7 +1211,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.layoutPolicy.update(this._mainContainerDimension.width, this._mainContainerDimension.height); const currentClass = this.layoutPolicy.viewportClass.get(); this.mainContainer.classList.toggle(LayoutClasses.PHONE_LAYOUT, currentClass === 'phone'); - this.mainContainer.classList.toggle(LayoutClasses.MOBILE_LAYOUT, this.layoutPolicy.isMobileLayout.get()); // When viewport class changes at runtime (e.g., device emulation toggle), // update part visibility and create/destroy mobile components @@ -1332,7 +1333,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic LayoutClasses.STATUSBAR_HIDDEN, // agents window never has a status bar this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.layoutPolicy.viewportClass.get() === 'phone' ? LayoutClasses.PHONE_LAYOUT : undefined, - this.layoutPolicy.isMobileLayout.get() ? LayoutClasses.MOBILE_LAYOUT : undefined, ]); } diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index ab53996dee424..a42408dbc50da 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -41,8 +41,7 @@ export const EditorMaximizedContext = new RawContextKey('editorMaximize //#region < --- Mobile Layout --- > -export const ViewportClassContext = new RawContextKey('sessionsViewportClass', 'desktop', localize('sessionsViewportClass', "The current viewport class: phone, tablet, or desktop")); -export const IsMobileLayoutContext = new RawContextKey('sessionsIsMobileLayout', false, localize('sessionsIsMobileLayout', "Whether the current layout is mobile (phone or tablet)")); +export const IsPhoneLayoutContext = new RawContextKey('sessionsIsPhoneLayout', false, localize('sessionsIsPhoneLayout', "Whether the current layout is the phone layout")); export const KeyboardVisibleContext = new RawContextKey('sessionsKeyboardVisible', false, localize('sessionsKeyboardVisible', "Whether the virtual keyboard is visible")); //#endregion diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index 761b61666449c..41d3005c63648 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -13,7 +13,7 @@ import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensi import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../common/changes.js'; import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import { ChangesTitleBarContribution } from './changesTitleBarWidget.js'; -import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import './changesViewActions.js'; import './checksActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; @@ -55,7 +55,7 @@ viewsRegistry.registerViews([{ canMoveView: false, weight: 100, order: 1, - when: IsMobileLayoutContext.negate(), + when: IsPhoneLayoutContext.negate(), windowVisibility: WindowVisibility.Sessions, }], changesViewContainer); diff --git a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts index ac5c321dbed16..0dd1cb31396db 100644 --- a/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { IsMobileLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -42,7 +42,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsMobileLayoutContext.negate()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts index a5ab212878437..85ed0bbdbf6cf 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/openInVSCode.contribution.ts @@ -16,7 +16,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { IProductService } from '../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { IsMobileLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { CopilotCLISessionType } from '../../../services/sessions/common/session.js'; @@ -44,7 +44,7 @@ registerAction2(class OpenSessionWorktreeInVSCodeAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsMobileLayoutContext.negate()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index 6d2380f9b84c1..6653b98cd8c86 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -14,7 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -53,7 +53,7 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp when: ContextKeyExpr.and( IsSessionsWindowContext, ChatContextKeys.agentSessionType.notEqualsTo(CopilotCloudSessionType.id), - IsMobileLayoutContext.negate(), + IsPhoneLayoutContext.negate(), ), }, ], diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts index b14a237722f92..c6823a72b5fe3 100644 --- a/src/vs/sessions/contrib/files/browser/files.contribution.ts +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -19,7 +19,7 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; import { SESSIONS_FILES_EMPTY_VIEW_ID, SESSIONS_FILES_VIEW_ID, SessionsExplorerEmptyView, SessionsExplorerView } from './filesView.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; export const SESSIONS_FILES_CONTAINER_ID = 'workbench.sessions.auxiliaryBar.filesContainer'; @@ -61,7 +61,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerView), canToggleVisibility: false, canMoveView: false, - when: ContextKeyExpr.and(WorkspaceFolderCountContext.notEqualsTo('0'), IsMobileLayoutContext.negate()), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.notEqualsTo('0'), IsPhoneLayoutContext.negate()), windowVisibility: WindowVisibility.Sessions, }], filesViewContainer); @@ -73,7 +73,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerEmptyView), canToggleVisibility: false, canMoveView: false, - when: ContextKeyExpr.and(WorkspaceFolderCountContext.isEqualTo('0'), IsMobileLayoutContext.negate()), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.isEqualTo('0'), IsPhoneLayoutContext.negate()), windowVisibility: WindowVisibility.Sessions, }], filesViewContainer); } diff --git a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts index eb78a01aadbef..d481f686e8c90 100644 --- a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts +++ b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts @@ -14,7 +14,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { OutputViewPane } from '../../../../workbench/contrib/output/browser/outputView.js'; import { OUTPUT_VIEW_ID } from '../../../../workbench/services/output/common/output.js'; -import { IsMobileLayoutContext } from '../../../common/contextkeys.js'; +import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; const SESSIONS_LOGS_CONTAINER_ID = 'workbench.sessions.panel.logsContainer'; @@ -60,7 +60,7 @@ class RegisterLogsViewContainerContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(OutputViewPane), canToggleVisibility: true, canMoveView: false, - when: IsMobileLayoutContext.negate(), + when: IsPhoneLayoutContext.negate(), windowVisibility: WindowVisibility.Sessions, }], logsViewContainer); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 2b706abba46e4..91d1d4d225ba4 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -407,19 +407,19 @@ /* ---- Mobile Layout: Touch Adaptations ---- */ /* Always show inline toolbar on mobile (no hover dependency) */ -.agent-sessions-workbench.mobile-layout .sessions-list .monaco-list-row .actions { +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row .actions { display: flex !important; visibility: visible !important; opacity: 1 !important; } /* Touch feedback on session list items */ -.agent-sessions-workbench.mobile-layout .sessions-list .monaco-list-row:active { +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row:active { background-color: var(--vscode-list-hoverBackground) !important; } /* Disable webkit touch callout on list items */ -.agent-sessions-workbench.mobile-layout .sessions-list .monaco-list-row { +.agent-sessions-workbench.phone-layout .sessions-list .monaco-list-row { -webkit-touch-callout: none; touch-action: manipulation; } diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 99a07bb81c368..640613a6630cd 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -19,7 +19,7 @@ import { TerminalCapability } from '../../../../platform/terminal/common/capabil import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { SessionsWelcomeVisibleContext, IsMobileLayoutContext } from '../../../common/contextkeys.js'; +import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CopilotCLISessionType, ISession } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; @@ -392,7 +392,7 @@ class OpenSessionInTerminalAction extends Action2 { id: Menus.TitleBarSessionMenu, group: 'navigation', order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsMobileLayoutContext.negate()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); } From 19f3f0a83033630971322482beccfe6a590a71d7 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 21:15:58 -0700 Subject: [PATCH 10/14] Fix desktop/tablet regressions: keep auxiliaryBar visible and use original sizes --- src/vs/sessions/browser/layoutPolicy.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts index 6ca7e21f6ed10..e4dfdbf60ea41 100644 --- a/src/vs/sessions/browser/layoutPolicy.ts +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -108,9 +108,10 @@ export class SessionsLayoutPolicy extends Disposable { case 'phone': return { sidebar: false, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; case 'tablet': - return { sidebar: true, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; case 'desktop': - return { sidebar: true, auxiliaryBar: false, panel: false, chatBar: true, editor: false }; + // Tablet and desktop share the standard multi-part workbench defaults. + // A dedicated tablet layout has not been designed yet. + return { sidebar: true, auxiliaryBar: true, panel: false, chatBar: true, editor: false }; } } @@ -133,13 +134,8 @@ export class SessionsLayoutPolicy extends Disposable { chatBarWidth: width, }; case 'tablet': - return { - sideBarSize: 250, - auxiliaryBarSize: 300, - panelSize: 250, - chatBarWidth: width - 250, - }; case 'desktop': + // Tablet currently falls back to desktop sizing. return { sideBarSize: 300, auxiliaryBarSize: 380, From 6e0dbc91940385990b06b2fd9d98fa8a41c27bc2 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 21:16:59 -0700 Subject: [PATCH 11/14] Gate phone layout on mobile OS (iOS/Android) instead of width alone --- src/vs/sessions/browser/layoutPolicy.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts index e4dfdbf60ea41..5808d42dc41f9 100644 --- a/src/vs/sessions/browser/layoutPolicy.ts +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -32,10 +32,22 @@ export interface IPartSizeDefaults { const PHONE_MAX_WIDTH = 640; const TABLET_MAX_WIDTH = 1024; +/** + * Whether the current platform is a phone/tablet OS. The phone layout is + * only applied on actual mobile devices so that resizing a desktop window + * below 640px does not switch the agents workbench into phone mode. + */ +const isMobilePlatform = isIOS || isAndroid; + /** * Classifies the viewport into one of three classes based on width. + * Phone and tablet classifications are gated on a mobile OS; desktop + * browsers and Electron always report `desktop` regardless of width. */ function classifyViewport(width: number): ViewportClass { + if (!isMobilePlatform) { + return 'desktop'; + } if (width < PHONE_MAX_WIDTH) { return 'phone'; } From 543d44b16019da5f4dec60cf33ebde5fa3cd2eaa Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Wed, 22 Apr 2026 21:41:09 -0700 Subject: [PATCH 12/14] Merge fix --- src/vs/sessions/sessions.web.main.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 2bf9d8d1edf16..6abba66db2c46 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -160,9 +160,6 @@ import './contrib/agentHost/browser/agentSessionSettings.contribution.js'; // Host filter dropdown in the titlebar (scopes the sessions list to a host) import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; -// Host filter dropdown in the titlebar (scopes the sessions list to a host) -import './contrib/remoteAgentHost/browser/hostFilter.contribution.js'; - // TODO: support agent feedback in web import './contrib/agentFeedback/browser/nullAgentFeedbackService.contribution.js'; import '../workbench/contrib/webview/browser/webview.web.contribution.js'; From 41760a2891df25f64e8eb026451873dc0a006665 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 05:01:11 +0000 Subject: [PATCH 13/14] sessions: reduce unnecessary mobile CSS !important and use isMobile platform flag Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/987a91a6-5cc8-4bb9-8943-ad08b4d6fdca Co-authored-by: rebornix <876920+rebornix@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 1 + src/vs/sessions/browser/layoutPolicy.ts | 4 ++-- src/vs/sessions/browser/media/style.css | 18 +++++++++--------- .../browser/parts/media/sidebarPart.css | 4 ++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index e22625bd3b037..a62f326e01330 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -664,6 +664,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-23 | Updated mobile layout policy platform detection to use shared `platform.isMobile`, and reduced phone-layout CSS `!important` usage where selector specificity already provides stable overrides. | | 2026-04-22 | Increased the sessions titlebar account widget's GitHub profile image from `16px × 16px` to `18px × 18px` while keeping the existing `22px × 22px` control footprint and avatar border treatment. | | 2026-04-22 | Added sessions-only toast offset overrides so notification toasts now use `right: 15px` in the default bottom-right placement and `left: 15px` in the bottom-left placement, matching the notification center spacing. | | 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts index 5808d42dc41f9..d062ed284577a 100644 --- a/src/vs/sessions/browser/layoutPolicy.ts +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -5,7 +5,7 @@ import { Disposable } from '../../base/common/lifecycle.js'; import { observableValue, derived, IObservable } from '../../base/common/observable.js'; -import { isIOS } from '../../base/common/platform.js'; +import { isIOS, isMobile } from '../../base/common/platform.js'; import { isAndroid } from '../../base/browser/browser.js'; import { Gesture } from '../../base/browser/touch.js'; @@ -37,7 +37,7 @@ const TABLET_MAX_WIDTH = 1024; * only applied on actual mobile devices so that resizing a desktop window * below 640px does not switch the agents workbench into phone mode. */ -const isMobilePlatform = isIOS || isAndroid; +const isMobilePlatform = isMobile; /** * Classifies the viewport into one of three classes based on width. diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 7bf0601e7bcea..94a4047fadced 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -707,12 +707,12 @@ right: 0 !important; width: 100% !important; max-width: 100% !important; - border-radius: 16px 16px 0 0 !important; + border-radius: 16px 16px 0 0; padding-bottom: env(safe-area-inset-bottom); } .agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list { - max-height: 50vh !important; + max-height: 50vh; } .agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list .monaco-list-row { @@ -747,8 +747,8 @@ /* Make dialogs near-full-width with larger buttons on phone */ .agent-sessions-workbench.phone-layout .monaco-dialog-box { - width: calc(100% - 32px) !important; - max-width: calc(100% - 32px) !important; + width: calc(100% - 32px); + max-width: calc(100% - 32px); } .agent-sessions-workbench.phone-layout .monaco-dialog-box .dialog-buttons-row .monaco-button { @@ -768,8 +768,8 @@ } .agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast { - width: 100% !important; - max-width: 100% !important; + width: 100%; + max-width: 100%; } .agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { @@ -780,12 +780,12 @@ /* Disable delayed hover cards on touch devices — they never trigger */ .agent-sessions-workbench.phone-layout .monaco-hover { - display: none !important; + display: none; } /* Exception: keep hovers that are explicitly triggered (e.g., info buttons) */ .agent-sessions-workbench.phone-layout .monaco-hover.visible-on-mobile { - display: block !important; + display: block; } /* ---- Phone Layout: Mobile Editor Modal ---- */ @@ -832,7 +832,7 @@ .agent-sessions-workbench.phone-layout textarea, .agent-sessions-workbench.phone-layout .monaco-inputbox input, .agent-sessions-workbench.phone-layout .chat-input-container textarea { - font-size: 16px !important; + font-size: 16px; } /* ---- Phone Layout: Native Scroll Preservation ---- */ diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index 06b464e5d0737..43e3e446665cc 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -86,8 +86,8 @@ /* The sidebar Part inside fills its container */ .agent-sessions-workbench.phone-layout .part.sidebar { - width: 100% !important; - height: 100% !important; + width: 100%; + height: 100%; } @keyframes sidebar-slide-in { From 43ffabfaa457717796c28a35e07a7cb43c5be0e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 05:08:33 +0000 Subject: [PATCH 14/14] docs(sessions): use repo-relative paths in MOBILE.md file map Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/e418098f-fd9a-4936-ad37-87b4f6a3a60c Co-authored-by: rebornix <876920+rebornix@users.noreply.github.com> --- src/vs/sessions/MOBILE.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/vs/sessions/MOBILE.md b/src/vs/sessions/MOBILE.md index 6ad57b7ca63fe..7b18d586d8c66 100644 --- a/src/vs/sessions/MOBILE.md +++ b/src/vs/sessions/MOBILE.md @@ -104,46 +104,46 @@ The workbench toggles the `phone-layout` CSS class on `layout()` and creates/des | File | Purpose | |------|---------| -| `src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts` | Extends `ChatBarPart`. Overrides `layout()` (no card margins) and `updateStyles()` (no inline card styles). | -| `src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts` | Extends `SidebarPart`. Overrides `updateStyles()` (no inline card/title styles). | -| `src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts` | Extends `AuxiliaryBarPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | -| `src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts` | Extends `PanelPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | +| `browser/parts/mobile/mobileChatBarPart.ts` | Extends `ChatBarPart`. Overrides `layout()` (no card margins) and `updateStyles()` (no inline card styles). | +| `browser/parts/mobile/mobileSidebarPart.ts` | Extends `SidebarPart`. Overrides `updateStyles()` (no inline card/title styles). | +| `browser/parts/mobile/mobileAuxiliaryBarPart.ts` | Extends `AuxiliaryBarPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | +| `browser/parts/mobile/mobilePanelPart.ts` | Extends `PanelPart`. Overrides `layout()` and `updateStyles()` (no card margins or inline styles). | ### Mobile Chrome Components | File | Purpose | |------|---------| -| `src/vs/sessions/browser/parts/mobile/mobileTopBar.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | -| `src/vs/sessions/browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | +| `browser/parts/mobile/mobileTopBar.ts` | Phone top bar: hamburger (☰), session title, new session (+). Emits `onDidClickHamburger`, `onDidClickNewSession`, `onDidClickTitle`. | +| `browser/parts/mobile/mobileChatShell.css` | **Single source of truth** for all phone-layout CSS: flex column layout, split-view-view absolute positioning, card chrome removal, part/content width overrides, sidebar title hiding, composite bar hiding, welcome page layout, sash hiding, button focus overrides, mobile pickers. | ### Layout & Navigation | File | Purpose | |------|---------| -| `src/vs/sessions/browser/layoutPolicy.ts` | `SessionsLayoutPolicy`: observable viewport classification (phone/tablet/desktop), platform flags (isIOS, isAndroid, isTouchDevice), part visibility and size defaults. | -| `src/vs/sessions/browser/mobileNavigationStack.ts` | `MobileNavigationStack`: Android back button integration via `history.pushState` / `popstate`. Supports `push()`, `pop()`, and `clear()`. | -| `src/vs/sessions/common/contextkeys.ts` | Phone context keys: `IsPhoneLayoutContext`, `KeyboardVisibleContext`. | +| `browser/layoutPolicy.ts` | `SessionsLayoutPolicy`: observable viewport classification (phone/tablet/desktop), platform flags (isIOS, isAndroid, isTouchDevice), part visibility and size defaults. | +| `browser/mobileNavigationStack.ts` | `MobileNavigationStack`: Android back button integration via `history.pushState` / `popstate`. Supports `push()`, `pop()`, and `clear()`. | +| `common/contextkeys.ts` | Phone context keys: `IsPhoneLayoutContext`, `KeyboardVisibleContext`. | ### Part Instantiation | File | Purpose | |------|---------| -| `src/vs/sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService`: checks viewport width at construction time and instantiates `Mobile*Part` vs desktop `*Part` classes accordingly. | +| `browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService`: checks viewport width at construction time and instantiates `Mobile*Part` vs desktop `*Part` classes accordingly. | ### Workbench Integration | File | Key Changes | |------|-------------| -| `src/vs/sessions/browser/workbench.ts` | Layout policy integration, MobileTopBar creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTopBar height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | -| `src/vs/sessions/browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. | +| `browser/workbench.ts` | Layout policy integration, MobileTopBar creation/destruction (via `DisposableStore`), sidebar drawer open/close with backdrop, viewport-class-change detection, window resize listener, grid height calculation (subtracts MobileTopBar height), titlebar grid visibility toggle, `ISessionsManagementService` for new session button. | +| `browser/parts/chatBarPart.ts` | `_lastLayout` changed from `private` to `protected` for mobile subclass access. | ### Styling | File | Purpose | |------|---------| -| `src/vs/sessions/browser/parts/mobile/mobileChatShell.css` | All phone-layout CSS (see above). | -| `src/vs/sessions/browser/parts/media/sidebarPart.css` | Sidebar drawer overlay CSS: 85% width, z-index 250, slide-in animation, backdrop. | -| `src/vs/sessions/browser/media/style.css` | Mobile overscroll containment, 44px touch targets, quick pick bottom sheets, context menu action sheets, dialog sizing, notification positioning, hover card suppression, editor modal full-screen. | +| `browser/parts/mobile/mobileChatShell.css` | All phone-layout CSS (see above). | +| `browser/parts/media/sidebarPart.css` | Sidebar drawer overlay CSS: 85% width, z-index 250, slide-in animation, backdrop. | +| `browser/media/style.css` | Mobile overscroll containment, 44px touch targets, quick pick bottom sheets, context menu action sheets, dialog sizing, notification positioning, hover card suppression, editor modal full-screen. | ## Remaining Work