Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/vs/sessions/LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
154 changes: 154 additions & 0 deletions src/vs/sessions/MOBILE.md
Original file line number Diff line number Diff line change
@@ -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 |
Comment thread
osortega marked this conversation as resolved.
|------|---------|
| `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.
159 changes: 159 additions & 0 deletions src/vs/sessions/browser/layoutPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<ViewportClass>(this, 'desktop');

/** Current viewport class derived from the most recent `update()` call. */
readonly viewportClass: IObservable<ViewportClass> = this._viewportClass;

/** `true` when the viewport class is `phone`. */
readonly isPhoneLayout: IObservable<boolean> = 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,
};
}
}
}
Loading
Loading