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/MOBILE.md b/src/vs/sessions/MOBILE.md new file mode 100644 index 0000000000000..7b18d586d8c66 --- /dev/null +++ b/src/vs/sessions/MOBILE.md @@ -0,0 +1,154 @@ +# 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()`. `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`. + +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 + +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: 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 | Phone Status | Mechanism | +|---------|--------------|-----------| +| Sessions list (sidebar) | ✅ Compatible | No gate | +| Chat views (ChatBar) | ✅ Compatible | No gate | +| 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 | + +### 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` (treated as desktop; no phone-specific chrome) +- **desktop**: `width ≥ 1024px` + +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 | +|-----|------|---------| +| `sessionsIsPhoneLayout` | `boolean` | `true` when the viewport is phone (< 640px) | +| `sessionsKeyboardVisible` | `boolean` | `true` when the 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 | +|------|---------| +| `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 | +|------|---------| +| `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 | +|------|---------| +| `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 | +|------|---------| +| `browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService`: checks viewport width at construction time and instantiates `Mobile*Part` vs desktop `*Part` classes accordingly. | + +### Workbench Integration + +| File | Key Changes | +|------|-------------| +| `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 | +|------|---------| +| `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 + +- **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 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 new file mode 100644 index 0000000000000..ded7d705e8b4e --- /dev/null +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * 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, isMobile } 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; + +/** + * 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 = isMobile; + +/** + * 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'; + } + 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`. */ + readonly isPhoneLayout: IObservable = derived(this, reader => { + return this._viewportClass.read(reader) === 'phone'; + }); + + 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': + case 'desktop': + // 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 }; + } + } + + /** + * 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': + case 'desktop': + // Tablet currently falls back to desktop sizing. + return { + sideBarSize: 300, + auxiliaryBarSize: 340, + panelSize: 300, + chatBarWidth: width - 300, + }; + } + } +} diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 5b6c61e90d0a0..228ae5d448f30 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -651,3 +651,261 @@ .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. */ + +/* + * Phone layout (< 640px) styles. Currently the only mobile form factor + * supported by the sessions workbench; tablet/larger viewports fall back + * to the desktop layout. + */ + +/* ---- Phone Layout: Overscroll Containment ---- */ + +/* Prevent body rubber-band on iOS and Chrome pull-to-refresh on Android */ +.agent-sessions-workbench.phone-layout .monaco-scrollable-element > .scrollable-element { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-layout .monaco-list { + overscroll-behavior: contain; +} + +/* ---- Phone Layout: Touch Target Sizing ---- */ + +/* Ensure interactive elements meet 44px minimum touch target */ +.agent-sessions-workbench.phone-layout .action-item > .action-label { + min-height: 44px; + min-width: 44px; +} + +/* Touch action for tap responsiveness */ +.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.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; +} + +/* 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; + padding-bottom: env(safe-area-inset-bottom); +} + +.agent-sessions-workbench.phone-layout .quick-input-widget .quick-input-list { + max-height: 50vh; +} + +.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); + max-width: calc(100% - 32px); +} + +.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%; + max-width: 100%; +} + +.agent-sessions-workbench.phone-layout .notifications-toasts .notification-toast .notification-toast-container { + border-radius: 12px; +} + +/* ---- Phone Layout: Hover Cards ---- */ + +/* Disable delayed hover cards on touch devices — they never trigger */ +.agent-sessions-workbench.phone-layout .monaco-hover { + 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; +} + +/* ---- 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); +} + +/* ---- Phone Layout: Input Auto-Zoom Prevention ---- */ + +/* iOS Safari zooms in on input focus when font-size < 16px. + 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; +} + +/* ---- Phone Layout: Native Scroll Preservation ---- */ + +/* Ensure chat content uses momentum scrolling on phone. + The -webkit-overflow-scrolling property is needed for older iOS. */ +.agent-sessions-workbench.phone-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..020022bf65c1f --- /dev/null +++ b/src/vs/sessions/browser/mobileNavigationStack.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * 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. + * + * 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._pendingSilentPops++; + mainWindow.history.back(); + return; + } + } + } + + private _pendingSilentPops = 0; + + private _onPopState(e: PopStateEvent): void { + if (this._pendingSilentPops > 0) { + this._pendingSilentPops--; + 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..43e3e446665cc 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%; + height: 100%; +} + +@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.phone-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..fa402bc65e1f4 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileAuxiliaryBarPart.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. + */ +export class MobileAuxiliaryBarPart extends AuxiliaryBarPart { + + override updateStyles(): void { + // 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 = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + } + } + + 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. + // AbstractPaneCompositePart.layout internally calls Part.layout. + AbstractPaneCompositePart.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..f4f82dcd031cb --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatBarPart.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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. 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 { + // 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) { + 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 (!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. + // 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); + } +} 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..9e65c868ed321 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -0,0 +1,359 @@ +/*--------------------------------------------------------------------------------------------- + * 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.phone-layout .interactive-session { + overscroll-behavior: contain; +} + +.agent-sessions-workbench.phone-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 .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 .new-chat-widget-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 .new-session-workspace-picker-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 .new-session-workspace-picker-container::before { + content: ''; + display: block; + width: 64px; + height: 64px; + margin-bottom: 16px; + 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 .session-workspace-picker { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 8px !important; + font-size: 16px !important; +} + +.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 .new-chat-input-container { + 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 .new-chat-bottom-container { + 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/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 new file mode 100644 index 0000000000000..2891360e96729 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobilePanelPart.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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. On tablet/desktop it falls + * back to the desktop behavior so runtime viewport transitions keep + * working. + */ +export class MobilePanelPart extends PanelPart { + + override updateStyles(): void { + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + 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 (!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. + // AbstractPaneCompositePart.layout internally calls Part.layout. + AbstractPaneCompositePart.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..f062ddfd2f4f8 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileSidebarPart.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +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. 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; 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(); + 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..26a1aed9642d9 --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileTopBar.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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'; +import { localize } from '../../../../nls.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'; + + // 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 + 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)); + this._register(addDisposableListener(hamburger, EventType.CLICK, () => this._onDidClickHamburger.fire())); + + // Session title + this.sessionTitleElement = append(this.element, $('div.mobile-session-title')); + 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)); + this._register(addDisposableListener(newSession, EventType.CLICK, () => this._onDidClickNewSession.fire())); + } + + 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 e012ecf4941cc..3789111d77c67 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -7,12 +7,12 @@ 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'; 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 { 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 @@ -92,7 +97,8 @@ enum LayoutClasses { STATUSBAR_HIDDEN = 'nostatusbar', EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND = 'experimental-shell-gradient-background', FULLSCREEN = 'fullscreen', - MAXIMIZED = 'maximized' + MAXIMIZED = 'maximized', + PHONE_LAYOUT = 'phone-layout' } //#endregion @@ -233,6 +239,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 }; @@ -263,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; @@ -281,6 +295,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private editorService!: IEditorService; private paneCompositeService!: IPaneCompositePartService; private viewDescriptorService!: IViewDescriptorService; + private sessionsManagementService!: ISessionsManagementService; //#endregion @@ -292,6 +307,26 @@ 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. + // 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 + 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'); @@ -391,6 +426,41 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editorMaximizedContext.set(this.isEditorMaximized()); })); + // Phone Layout Context Key + const contextKeyService = accessor.get(IContextKeyService); + const isPhoneLayoutCtx = IsPhoneLayoutContext.bindTo(contextKeyService); + this._register(autorun(reader => { + isPhoneLayoutCtx.set(this.layoutPolicy.viewportClass.read(reader) === 'phone'); + })); + + // 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); + const KEYBOARD_HEIGHT_THRESHOLD_PX = 100; + + const onViewportResize = () => { + const vp = mainWindow.visualViewport; + if (!vp) { + return; + } + 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 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); @@ -400,6 +470,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); @@ -547,6 +622,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([ @@ -593,6 +680,76 @@ 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 readonly sidebarDrawerBackdropDisposables = this._register(new DisposableStore()); + + private toggleMobileSidebarDrawer(): void { + const isOpen = this.partVisibility.sidebar; + if (isOpen) { + this.closeMobileSidebarDrawer(); + } else { + this.openMobileSidebarDrawer(); + } + } + + private openMobileSidebarDrawer(): void { + // Show backdrop — created fresh each open so its click listener is + // tracked by a DisposableStore and cleaned up on close. + if (!this.sidebarDrawerBackdrop) { + 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); + + // 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 and dispose its listener. + this.sidebarDrawerBackdropDisposables.clear(); + this.sidebarDrawerBackdrop = undefined; + + // 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, @@ -742,6 +899,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 @@ -770,17 +928,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 { @@ -801,6 +957,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 { @@ -871,6 +1032,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 { @@ -890,25 +1069,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 = 600; - const auxiliaryBarSize = 340; - 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 = { @@ -988,16 +1185,78 @@ 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'); + + // 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); + } + } + + // 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; + 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 @@ -1011,7 +1270,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 = ''; @@ -1022,17 +1284,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 { @@ -1053,7 +1317,8 @@ 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, ]); } @@ -1163,7 +1428,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: diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 385069bd358d7..a42408dbc50da 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -38,3 +38,10 @@ 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 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 dfbd37604e9d0..2b4c7516dc002 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 { IsPhoneLayoutContext } 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: IsPhoneLayoutContext.negate(), windowEnablement: WindowEnablement.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..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 { 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()), + 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 067188f31e1c8..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 { 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()), + 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 cb943ea95b252..6653b98cd8c86 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 { 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'; @@ -52,6 +53,7 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp when: ContextKeyExpr.and( IsSessionsWindowContext, ChatContextKeys.agentSessionType.notEqualsTo(CopilotCloudSessionType.id), + 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 28b17329f7b9c..9277f6924b36e 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 { IsPhoneLayoutContext } 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'), IsPhoneLayoutContext.negate()), windowEnablement: WindowEnablement.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'), IsPhoneLayoutContext.negate()), windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 884c9db5f6f87..91d1d4d225ba4 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.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.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.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 11dfd1e6446e1..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 } 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()), + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsPhoneLayoutContext.negate()), }] }); }