Fix long session timeline scrolling (#6526)

* fix(web-shell): hide long session timeline scrollbar

* fix(web-shell): lift timeline tooltip above popovers

* fix(web-shell): refine timeline tooltip behavior

* fix(web-shell): keep timeline tooltip anchored

* fix(web-shell): keep timeline tooltip below modals

* fix(web-shell): harden timeline tooltip recentering

* fix(web-shell): drop unused timeline tooltip var

* fix(web-shell): keep timeline programmatic scroll guard through frame

* fix(web-shell): preserve timeline tooltip on focus scroll

* ci(web-shell): add smoke test script
This commit is contained in:
callmeYe 2026-07-09 19:43:21 +08:00 committed by GitHub
parent 2a1807f08d
commit 0907edb909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 812 additions and 153 deletions

View file

@ -438,14 +438,14 @@ If you prefer to manually configure Coding Plan models, you can add them to your
The effective auth/model/credential values are chosen per field using the following precedence (first present wins). You can combine `--auth-type` with `--model` to point directly at a provider entry; these CLI flags run before other layers.
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
| -------------------------- | ----------------------------------- | ----------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------ | ---------------------- | --------------------------------- |
| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — |
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
| CLI arguments | `--auth-type` | `--model` | `--openai-api-key` | `--openai-base-url` | — | — |
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3.5-plus`) | — | — | — | `Config.getProxy()` if configured |
| Layer (highest → lowest) | authType | model | apiKey | baseUrl | apiKeyEnvKey | proxy |
| -------------------------- | ----------------------------------- | ----------------------------------------------- | ------------------------------------------------- | -------------------------------------------------- | ---------------------- | --------------------------------- |
| Programmatic overrides | `/auth` | `/auth` input | `/auth` input | `/auth` input | — | — |
| Model provider selection | — | `modelProvider.id` | `env[modelProvider.envKey]` | `modelProvider.baseUrl` | `modelProvider.envKey` | — |
| CLI arguments | `--auth-type` | `--model` | `--openai-api-key` | `--openai-base-url` | — | — |
| Environment variables | — | Provider-specific mapping (e.g. `OPENAI_MODEL`) | Provider-specific mapping (e.g. `OPENAI_API_KEY`) | Provider-specific mapping (e.g. `OPENAI_BASE_URL`) | — | — |
| Settings (`settings.json`) | `security.auth.selectedType` | `model.name` | `security.auth.apiKey` | `security.auth.baseUrl` | — | — |
| Default / computed | Falls back to `AuthType.QWEN_OAUTH` | Built-in default (OpenAI ⇒ `qwen3.5-plus`) | — | — | — | `Config.getProxy()` if configured |
\*When present, CLI auth flags override settings. Otherwise, `security.auth.selectedType` or the implicit default determine the auth type. Qwen OAuth and OpenAI are the only auth types surfaced without extra configuration.

View file

@ -76,43 +76,43 @@ Commands specifically for controlling interface and output language.
Commands for managing AI tools and models.
| Command | Description | Usage Examples |
| ----------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc`, `/mcp nodesc`, `/mcp schema` |
| `/import-config` | Import MCP servers from Claude configs | `/import-config all`, `/import-config claude-code`, `/import-config claude-desktop --scope user\|project` |
| `/tools` | Display currently available tool list | `/tools`, `/tools desc` |
| `/skills` | Open the Skills panel to browse, search, toggle, and launch skills | `/skills`, `/<skill-name>` |
| `/plan` | Switch to plan mode or exit plan mode | `/plan`, `/plan <task>`, `/plan exit` |
| `/approval-mode` | Change the tool-approval mode (current session only) | `/approval-mode`, `/approval-mode auto-edit` |
| → `plan` | Analysis only, no execution (secure review) | `/approval-mode plan` |
| → `default` | Require approval for edits (daily use) | `/approval-mode default` |
| → `auto-edit` | Auto-approve edits (trusted environment) | `/approval-mode auto-edit` |
| → `auto` | Classifier-evaluated approval (autonomous) | `/approval-mode auto` |
| → `yolo` | Auto-approve everything (quick prototyping) | `/approval-mode yolo` |
| `/model` | Switch model used in current session | `/model`, `/model <model-id>` (switch immediately) |
| `/model --fast` | Set a lighter model for prompt suggestions | `/model --fast qwen3-coder-flash` |
| `/model --voice` | Set the model used for voice transcription | `/model --voice <model-id>` |
| `/model --vision` | Set the vision-bridge model used to transcribe images for a text-only main model | `/model --vision <model-id>` |
| `/effort` | Set reasoning effort for thinking-capable models | `/effort` (opens picker), `/effort high` (low/medium/high/xhigh/max; mapped & clamped per provider) |
| `/extensions` | Manage extensions | `/extensions list`, `/extensions manage` |
| → `list` | List installed extensions | `/extensions list` |
| → `manage` | Manage installed extensions (interactive) | `/extensions manage` |
| → `explore` | Open extensions page in browser | `/extensions explore <Gemini\|ClaudeCode>` |
| → `install` | Install an extension from a git repo or path | `/extensions install <repo-or-path>` |
| `/memory` | Open the Memory Manager dialog | `/memory` |
| `/remember` | Save a durable memory | `/remember Prefer terse responses` |
| `/forget` | Remove matching entries from auto-memory | `/forget <query>` |
| `/dream` | Manually run auto-memory consolidation | `/dream` |
| `/hooks` | Manage Qwen Code hooks | `/hooks`, `/hooks list` |
| `/reload-plugins` | Reload extension changes (commands, skills, agents, hooks, MCP/LSP servers) from disk | `/reload-plugins` |
| `/permissions` | Manage permission rules | `/permissions` |
| `/agents` | Manage subagents | `/agents manage`, `/agents create` |
| `/arena` | Manage Arena sessions | `/arena start`, `/arena stop`, `/arena status`, `/arena select` (alias `choose`) |
| `/goal` | Set a goal — keep working until condition met | `/goal <condition>`, `/goal clear` |
| `/tasks` | List background tasks | `/tasks` |
| `/workflows` | Inspect workflow runs | `/workflows`, `/workflows <runId>` |
| `/lsp` | Show LSP server status | `/lsp` |
| `/trust` | Manage folder trust settings | `/trust` |
| Command | Description | Usage Examples |
| ----------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `/mcp` | List configured MCP servers and tools | `/mcp`, `/mcp desc`, `/mcp nodesc`, `/mcp schema` |
| `/import-config` | Import MCP servers from Claude configs | `/import-config all`, `/import-config claude-code`, `/import-config claude-desktop --scope user\|project` |
| `/tools` | Display currently available tool list | `/tools`, `/tools desc` |
| `/skills` | Open the Skills panel to browse, search, toggle, and launch skills | `/skills`, `/<skill-name>` |
| `/plan` | Switch to plan mode or exit plan mode | `/plan`, `/plan <task>`, `/plan exit` |
| `/approval-mode` | Change the tool-approval mode (current session only) | `/approval-mode`, `/approval-mode auto-edit` |
| → `plan` | Analysis only, no execution (secure review) | `/approval-mode plan` |
| → `default` | Require approval for edits (daily use) | `/approval-mode default` |
| → `auto-edit` | Auto-approve edits (trusted environment) | `/approval-mode auto-edit` |
| → `auto` | Classifier-evaluated approval (autonomous) | `/approval-mode auto` |
| → `yolo` | Auto-approve everything (quick prototyping) | `/approval-mode yolo` |
| `/model` | Switch model used in current session | `/model`, `/model <model-id>` (switch immediately) |
| `/model --fast` | Set a lighter model for prompt suggestions | `/model --fast qwen3-coder-flash` |
| `/model --voice` | Set the model used for voice transcription | `/model --voice <model-id>` |
| `/model --vision` | Set the vision-bridge model used to transcribe images for a text-only main model | `/model --vision <model-id>` |
| `/effort` | Set reasoning effort for thinking-capable models | `/effort` (opens picker), `/effort high` (low/medium/high/xhigh/max; mapped & clamped per provider) |
| `/extensions` | Manage extensions | `/extensions list`, `/extensions manage` |
| → `list` | List installed extensions | `/extensions list` |
| → `manage` | Manage installed extensions (interactive) | `/extensions manage` |
| → `explore` | Open extensions page in browser | `/extensions explore <Gemini\|ClaudeCode>` |
| → `install` | Install an extension from a git repo or path | `/extensions install <repo-or-path>` |
| `/memory` | Open the Memory Manager dialog | `/memory` |
| `/remember` | Save a durable memory | `/remember Prefer terse responses` |
| `/forget` | Remove matching entries from auto-memory | `/forget <query>` |
| `/dream` | Manually run auto-memory consolidation | `/dream` |
| `/hooks` | Manage Qwen Code hooks | `/hooks`, `/hooks list` |
| `/reload-plugins` | Reload extension changes (commands, skills, agents, hooks, MCP/LSP servers) from disk | `/reload-plugins` |
| `/permissions` | Manage permission rules | `/permissions` |
| `/agents` | Manage subagents | `/agents manage`, `/agents create` |
| `/arena` | Manage Arena sessions | `/arena start`, `/arena stop`, `/arena status`, `/arena select` (alias `choose`) |
| `/goal` | Set a goal — keep working until condition met | `/goal <condition>`, `/goal clear` |
| `/tasks` | List background tasks | `/tasks` |
| `/workflows` | Inspect workflow runs | `/workflows`, `/workflows <runId>` |
| `/lsp` | Show LSP server status | `/lsp` |
| `/trust` | Manage folder trust settings | `/trust` |
> [!warning]
>
@ -327,7 +327,7 @@ Commands for obtaining information and performing system settings.
| `/stats monthly` | Show monthly token usage statistics | `/stats monthly` (alias `month`), `/stats month [YYYY-MM]` |
| `/stats export` | Export usage statistics to CSV or JSON | `/stats export <daily\|monthly> [date\|month] [--format csv\|json] [--output path]` |
| `/settings` | Open settings editor | `/settings` |
| `/config` | Get or set any setting by dot-path key (writes to user settings) | `/config` (list all), `/config <key>`, `/config <key>=<value>` |
| `/config` | Get or set any setting by dot-path key (writes to user settings) | `/config` (list all), `/config <key>`, `/config <key>=<value>` |
| `/auth` | Change authentication method | `/auth`, `/connect`, `/login` |
| `/doctor` | Run installation and environment diagnostics | `/doctor`, `/doctor memory` |
| → `memory` | Show current process memory diagnostics | `/doctor memory [--json] [--sample] [--snapshot]` |

View file

@ -18,9 +18,9 @@ To disable it entirely — which also prevents the native driver from being down
{
"tools": {
"computerUse": {
"enabled": false
}
}
"enabled": false,
},
},
}
```
@ -37,22 +37,22 @@ On macOS, desktop automation requires two system permissions:
- **Accessibility** — to read window/UI state and synthesize input
- **Screen Recording** — to capture screenshots
On first use the driver walks you through granting these via the standard macOS system dialogs. The agent can also check permission status on demand (the `check_permissions` tool). Because macOS attributes permission grants to the *responsible* process, grants may need to be given to the terminal or IDE that launched Qwen Code.
On first use the driver walks you through granting these via the standard macOS system dialogs. The agent can also check permission status on demand (the `check_permissions` tool). Because macOS attributes permission grants to the _responsible_ process, grants may need to be given to the terminal or IDE that launched Qwen Code.
## What the agent can do
The full `cua-driver` tool surface is exposed. Highlights:
| Category | Tools (a selection) |
| ------------- | ----------------------------------------------------------------------------------- |
| Mouse | `click`, `double_click`, `right_click`, `drag`, `move_cursor`, `scroll` |
| Keyboard | `type_text`, `press_key`, `hotkey` |
| Windows / UI | `list_windows`, `get_window_state`, `get_accessibility_tree`, `set_value`, `zoom` |
| Apps | `launch_app`, `list_apps`, `bring_to_front`, `kill_app` |
| Browser pages | `page` (execute JavaScript, read text, query the DOM, click elements) |
| Screenshots | `get_window_state` (captures a PNG), `page` |
| Recording | `start_recording`, `stop_recording`, `replay_trajectory` (record/replay a session) |
| Sessions | `start_session`, `end_session`, agent-cursor overlay controls |
| Category | Tools (a selection) |
| ------------- | ---------------------------------------------------------------------------------- |
| Mouse | `click`, `double_click`, `right_click`, `drag`, `move_cursor`, `scroll` |
| Keyboard | `type_text`, `press_key`, `hotkey` |
| Windows / UI | `list_windows`, `get_window_state`, `get_accessibility_tree`, `set_value`, `zoom` |
| Apps | `launch_app`, `list_apps`, `bring_to_front`, `kill_app` |
| Browser pages | `page` (execute JavaScript, read text, query the DOM, click elements) |
| Screenshots | `get_window_state` (captures a PNG), `page` |
| Recording | `start_recording`, `stop_recording`, `replay_trajectory` (record/replay a session) |
| Sessions | `start_session`, `end_session`, agent-cursor overlay controls |
Element-addressed actions are preferred over raw pixel coordinates: `get_window_state` returns a Markdown rendering of a window's accessibility tree with a stable `element_index` for each actionable element, which the input tools can target directly.
@ -62,11 +62,11 @@ Support is most complete on macOS; some tools are platform-specific (for example
All Computer Use settings live under `tools.computerUse` in `settings.json`. See the [Settings reference](../configuration/settings.md) for the authoritative list.
| Setting | Type | Default | Description |
| ---------------------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools.computerUse.enabled` | boolean | `true` | Register the `computer_use__*` tools. When `false`, the driver is never downloaded or spawned. |
| Setting | Type | Default | Description |
| ------------------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tools.computerUse.enabled` | boolean | `true` | Register the `computer_use__*` tools. When `false`, the driver is never downloaded or spawned. |
| `tools.computerUse.maxImageDimension` | number | `-1` | Longest-edge pixel cap for screenshots. `-1` keeps the driver's default (1568); `0` disables resizing (full resolution); a positive value caps the longest edge. Lower caps cut vision-token cost. Env override: `QWEN_COMPUTER_USE_MAX_IMAGE_DIMENSION`. |
| `tools.computerUse.idleTimeoutMs` | number | `300000` | Milliseconds to keep the driver process alive after the last `computer_use__*` call (default 5 minutes). `0` keeps it running until Qwen Code exits. |
| `tools.computerUse.idleTimeoutMs` | number | `300000` | Milliseconds to keep the driver process alive after the last `computer_use__*` call (default 5 minutes). `0` keeps it running until Qwen Code exits. |
All three settings require a restart to take effect.

View file

@ -309,7 +309,7 @@ Notes:
| `--channel <name\|all>` | — | Experimental daemon-managed channel worker. Repeat the flag to select multiple configured channels, or pass `all` to start every configured channel. `all` cannot be combined with named channels. Selected channel `cwd` values must resolve to the daemon workspace. The worker is owned by `qwen serve`; stop the daemon to stop serve-managed channels. |
| `--max-connections <n>` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count — slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. |
| `--event-ring-size <n>` | `8000` | Per-session SSE replay ring depth (#3803 §02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. |
| `--compacted-replay-max-bytes <n>` | `4194304` | Per-live-session byte cap for the retained replay events in the bounded snapshot returned by `POST /session/:id/load`. The cap applies to `compactedReplay`; the current in-flight `liveJournal` remains uncapped. Values must be positive safe integers; invalid values fail at boot, and the hard ceiling is 256 MiB. When older retained replay is dropped, the snapshot begins with `history_truncated`. This does not limit the on-disk transcript. |
| `--compacted-replay-max-bytes <n>` | `4194304` | Per-live-session byte cap for the retained replay events in the bounded snapshot returned by `POST /session/:id/load`. The cap applies to `compactedReplay`; the current in-flight `liveJournal` remains uncapped. Values must be positive safe integers; invalid values fail at boot, and the hard ceiling is 256 MiB. When older retained replay is dropped, the snapshot begins with `history_truncated`. This does not limit the on-disk transcript. |
| `--mcp-client-budget <n>` | — | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. |
| `--mcp-budget-mode <m>` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at ≥75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. |
| `--http-bridge` | `true` | Stage 1 mode: one `qwen --acp` child per daemon (bound to one workspace at boot, per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02); N sessions multiplex onto that child via ACP `newSession()`. Stage 2 native in-process becomes available later. |

View file

@ -960,7 +960,9 @@ export const useSlashCommandProcessor = (
return {
type: 'submit_prompt',
content: mergedContent,
...(firstModelOverride ? { modelOverride: firstModelOverride } : {}),
...(firstModelOverride
? { modelOverride: firstModelOverride }
: {}),
...(onCompleteCallbacks.length
? {
onComplete: async () => {

View file

@ -252,6 +252,10 @@ const toggleRow = (c: HTMLElement, turnId: string) =>
toggle(c, turnId).closest('[role="button"]') as HTMLElement;
const click = (el: Element) =>
act(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true })));
const focusIn = (el: Element) =>
act(() => el.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
const focusOut = (el: Element) =>
act(() => el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
const nextFrame = () =>
act(
() =>
@ -398,11 +402,9 @@ describe('MessageList — turn collapse (DOM)', () => {
expect(entries[0]?.getAttribute('data-node-kinds')).toBe(
'thought,commentary,tool,plan',
);
const details = Array.from(
c.querySelectorAll('[data-testid="session-timeline-detail"]'),
);
expect(details).toHaveLength(4);
expect(details[0]?.getAttribute('data-detail')).toBe('answer');
expect(
document.querySelectorAll('[data-testid="session-timeline-detail"]'),
).toHaveLength(0);
const buttons = Array.from(
c.querySelectorAll<HTMLButtonElement>(
'[data-testid="session-timeline-entry"] button',
@ -422,6 +424,367 @@ describe('MessageList — turn collapse (DOM)', () => {
rectSpy.mockRestore();
});
it('keeps a long session timeline scrollable and preserves first-entry selection', async () => {
const rectSpy = mockMessageListWidth(1200);
const offsetTopSpy = vi
.spyOn(HTMLElement.prototype, 'offsetTop', 'get')
.mockImplementation(function (this: HTMLElement) {
const index = this.getAttribute('data-timeline-index');
return index === null ? 0 : 240 + Number(index) * 60;
});
const offsetHeightSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-timeline-index') ? 3 : 0;
});
const clientHeightSpy = vi
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 220
: 0;
});
const scrollHeightSpy = vi
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 5200
: 0;
});
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
try {
const c = mount(simpleTurns(80));
await nextFrame();
const viewport = c.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(viewport).not.toBeNull();
expect(viewport!.scrollTop).toBeGreaterThan(0);
const entries = Array.from(
c.querySelectorAll('[data-testid="session-timeline-entry"]'),
);
expect(entries).toHaveLength(80);
expect(entries[0]?.getAttribute('data-turn-id')).toBe('u1');
expect(entries[0]?.getAttribute('data-timeline-index')).toBe('0');
expect(entries[79]?.getAttribute('data-turn-id')).toBe('u80');
expect(entries[79]?.getAttribute('data-timeline-index')).toBe('79');
expect(
entries[0]?.closest('[data-testid="session-timeline-viewport"]'),
).toBe(viewport);
click(entries[0]!.querySelector('button')!);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
} finally {
scrollIntoView.mockRestore();
scrollHeightSpy.mockRestore();
clientHeightSpy.mockRestore();
offsetHeightSpy.mockRestore();
offsetTopSpy.mockRestore();
rectSpy.mockRestore();
}
});
it('renders timeline details as one body-level tooltip outside the timeline stack', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4));
await nextFrame();
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
expect(firstEntryButton).not.toBeNull();
focusIn(firstEntryButton!);
const detail = document.querySelector(
'[data-testid="session-timeline-detail"]',
);
expect(detail).not.toBeNull();
expect(detail?.getAttribute('data-detail')).toBe('answer');
expect(
detail?.closest('[data-testid="session-timeline-viewport"]'),
).toBeNull();
expect(detail?.closest('[data-testid="session-timeline"]')).toBeNull();
expect(detail?.parentElement).toBe(document.body);
expect(c.contains(detail!)).toBe(false);
expect(detail?.id).toBe('session-timeline-detail-tooltip');
expect(firstEntryButton?.getAttribute('aria-describedby')).toBe(
'session-timeline-detail-tooltip',
);
focusOut(firstEntryButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
expect(firstEntryButton?.hasAttribute('aria-describedby')).toBe(false);
rectSpy.mockRestore();
});
it('clamps timeline details to the viewport edge', async () => {
const originalInnerHeight = window.innerHeight;
const rect = (
width: number,
height: number,
top: number,
left = 0,
): DOMRect => ({
width,
height,
top,
right: left + width,
bottom: top + height,
left,
x: left,
y: top,
toJSON: () => ({}),
});
let detailRect = rect(240, 50, -5, 80);
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: HTMLElement) {
if (this.getAttribute('data-testid') === 'session-timeline-detail') {
return detailRect;
}
const item = this.closest('[data-testid="session-timeline-entry"]');
if (item) return rect(58, 16, 20, 12);
return rect(1200, 600, 0);
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 100,
});
const c = mount(simpleTurns(4));
await nextFrame();
try {
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
expect(firstEntryButton).not.toBeNull();
focusIn(firstEntryButton!);
let detail = document.querySelector<HTMLElement>(
'[data-testid="session-timeline-detail"]',
);
expect(detail?.style.top).toBe('45px');
focusOut(firstEntryButton!);
detailRect = rect(240, 100, 30, 80);
focusIn(firstEntryButton!);
detail = document.querySelector<HTMLElement>(
'[data-testid="session-timeline-detail"]',
);
expect(detail?.style.top).toBe('-14px');
} finally {
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: originalInnerHeight,
});
rectSpy.mockRestore();
}
});
it('keeps timeline details during current-turn centering but hides them on user scroll', async () => {
const rectSpy = mockMessageListWidth(1200);
const offsetTopSpy = vi
.spyOn(HTMLElement.prototype, 'offsetTop', 'get')
.mockImplementation(function (this: HTMLElement) {
const index = this.getAttribute('data-timeline-index');
return index === null ? 0 : 240 + Number(index) * 60;
});
const offsetHeightSpy = vi
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.hasAttribute('data-timeline-index') ? 3 : 0;
});
const clientHeightSpy = vi
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 220
: 0;
});
const scrollHeightSpy = vi
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-testid') === 'session-timeline-viewport'
? 1200
: 0;
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
try {
renderInto(root, simpleTurns(3));
await nextFrame();
renderInto(root, simpleTurns(80));
const viewport = container.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(viewport).not.toBeNull();
expect(viewport!.scrollTop).toBeGreaterThan(0);
const currentButton = container.querySelector<HTMLButtonElement>(
'[data-turn-id="u80"] button',
);
expect(currentButton).not.toBeNull();
focusIn(currentButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
act(() =>
viewport!.dispatchEvent(new Event('scroll', { bubbles: true })),
);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
await nextFrame();
act(() =>
viewport!.dispatchEvent(new Event('scroll', { bubbles: true })),
);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
} finally {
scrollHeightSpy.mockRestore();
clientHeightSpy.mockRestore();
offsetHeightSpy.mockRestore();
offsetTopSpy.mockRestore();
rectSpy.mockRestore();
}
});
it('hides timeline details when the focused marker moves out of view', async () => {
let markerOffset = 0;
const rect = (
width: number,
height: number,
top: number,
left = 0,
): DOMRect => ({
width,
height,
top,
right: left + width,
bottom: top + height,
left,
x: left,
y: top,
toJSON: () => ({}),
});
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: HTMLElement) {
if (this.getAttribute('data-testid') === 'session-timeline-viewport') {
return rect(70, 220, 0);
}
const item = this.closest('[data-testid="session-timeline-entry"]');
if (item) {
const index = Number(item.getAttribute('data-timeline-index'));
return rect(58, 16, 40 + index * 60 - markerOffset);
}
return rect(1200, 600, 0);
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
try {
renderInto(root, simpleTurns(4));
await nextFrame();
const focusedButton = container.querySelector<HTMLButtonElement>(
'[data-turn-id="u2"] button',
);
expect(focusedButton).not.toBeNull();
focusIn(focusedButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
markerOffset = 700;
act(() => window.dispatchEvent(new Event('resize')));
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
expect(
container
.querySelector<HTMLButtonElement>('[data-turn-id="u2"] button')
?.hasAttribute('aria-describedby'),
).toBe(false);
} finally {
rectSpy.mockRestore();
}
});
it('keeps timeline details when focus scrolls the timeline viewport', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4));
await nextFrame();
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
const viewport = c.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(firstEntryButton).not.toBeNull();
expect(viewport).not.toBeNull();
focusIn(firstEntryButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
act(() => viewport!.dispatchEvent(new Event('scroll', { bubbles: true })));
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
expect(firstEntryButton?.hasAttribute('aria-describedby')).toBe(true);
rectSpy.mockRestore();
});
it('hides timeline details when the user scrolls the timeline viewport', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4));
await nextFrame();
const firstEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
const viewport = c.querySelector<HTMLElement>(
'[data-testid="session-timeline-viewport"]',
);
expect(firstEntryButton).not.toBeNull();
expect(viewport).not.toBeNull();
focusIn(firstEntryButton!);
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).not.toBeNull();
await nextFrame();
act(() => viewport!.dispatchEvent(new Event('scroll', { bubbles: true })));
expect(
document.querySelector('[data-testid="session-timeline-detail"]'),
).toBeNull();
expect(firstEntryButton?.hasAttribute('aria-describedby')).toBe(false);
rectSpy.mockRestore();
});
it('renders scheduled task marker when source is present', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount([
@ -438,8 +801,14 @@ describe('MessageList — turn collapse (DOM)', () => {
]);
await nextFrame();
const scheduledDetail = c.querySelector(
'[data-turn-id="u1"] [data-testid="session-timeline-detail"]',
const scheduledButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u1"] button',
);
expect(scheduledButton).not.toBeNull();
focusIn(scheduledButton!);
const scheduledDetail = document.querySelector(
'[data-testid="session-timeline-detail"]',
);
expect(scheduledDetail?.getAttribute('data-scheduled-task')).toBe('true');
expect(

View file

@ -182,7 +182,7 @@
.sessionTimelineLayer {
position: sticky;
top: 50%;
top: 50vh;
z-index: 4;
width: 0 !important;
height: 0;
@ -204,13 +204,54 @@
transform: translateY(-50%);
}
.sessionTimelineViewport {
width: 70px;
max-height: min(640px, max(220px, calc(100vh - 220px)));
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-color: transparent transparent !important;
scrollbar-width: none !important;
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
rgb(0 0 0 / 18%) 10px,
black 34px,
black calc(100% - 34px),
rgb(0 0 0 / 18%) calc(100% - 10px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0,
rgb(0 0 0 / 18%) 10px,
black 34px,
black calc(100% - 34px),
rgb(0 0 0 / 18%) calc(100% - 10px),
transparent 100%
);
}
.sessionTimelineViewport.sessionTimelineViewport::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
background: transparent !important;
}
.sessionTimelineViewport.sessionTimelineViewport::-webkit-scrollbar-track,
.sessionTimelineViewport.sessionTimelineViewport::-webkit-scrollbar-thumb,
.sessionTimelineViewport.sessionTimelineViewport::-webkit-scrollbar-thumb:hover {
border: 0 !important;
background: transparent !important;
}
.sessionTimelineList {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
padding: 32px 0;
list-style: none;
}
@ -341,10 +382,8 @@
}
.sessionTimelineDetails {
position: absolute;
top: 50%;
left: 56px;
z-index: 1;
position: fixed;
z-index: 999;
box-sizing: border-box;
display: grid;
gap: 6px;
@ -354,20 +393,26 @@
border-radius: 14px;
background: color-mix(in srgb, var(--background) 96%, white);
box-shadow: 0 10px 28px color-mix(in srgb, black 10%, transparent);
opacity: 0;
color: var(--foreground);
font-family: var(--font-sans, system-ui, sans-serif);
pointer-events: none;
text-align: left;
transform: translate3d(-8px, -50%, 0) scale(0.985);
transform-origin: left center;
transition:
opacity 160ms cubic-bezier(0.25, 1, 0.5, 1),
transform 180ms cubic-bezier(0.25, 1, 0.5, 1);
}
.sessionTimelineButton:hover .sessionTimelineDetails,
.sessionTimelineButton:focus-visible .sessionTimelineDetails {
opacity: 1;
transform: translate3d(0, -50%, 0) scale(1);
transform-origin: left center;
animation: sessionTimelineDetailsIn 120ms ease-out;
}
@keyframes sessionTimelineDetailsIn {
from {
opacity: 0;
transform: translate3d(-4px, -50%, 0) scale(0.98);
}
to {
opacity: 1;
transform: translate3d(0, -50%, 0) scale(1);
}
}
.sessionTimelineDetailsTitle {
@ -409,10 +454,13 @@
}
@media (prefers-reduced-motion: reduce) {
.sessionTimelineTick,
.sessionTimelineDetails {
.sessionTimelineTick {
transition-duration: 0.01ms;
}
.sessionTimelineDetails {
animation: none;
}
}
@media (max-width: 720px) {

View file

@ -9,10 +9,13 @@ import {
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
type FocusEvent as ReactFocusEvent,
type MouseEvent as ReactMouseEvent,
type MutableRefObject,
} from 'react';
import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Message, ACPToolCall, TurnCollapseHead } from '../adapters/types';
import type { PermissionRequest } from '../adapters/types';
@ -1668,6 +1671,24 @@ const SESSION_TIMELINE_KIND_LABEL: Record<TurnTimelineNodeKind, string> = {
none: 'turn',
};
type SessionTimelineTooltip = {
entry: SessionTimelineEntry;
top: number;
left: number;
clamped: boolean;
themeVars: CSSProperties;
};
const SESSION_TIMELINE_TOOLTIP_THEME_VARS = [
'--background',
'--foreground',
'--muted-foreground',
'--border',
'--font-sans',
];
const SESSION_TIMELINE_TOOLTIP_ID = 'session-timeline-detail-tooltip';
const SessionTimeline = memo(function SessionTimeline({
entries,
currentTurnId,
@ -1682,80 +1703,299 @@ const SessionTimeline = memo(function SessionTimeline({
onSelect: (turnId: string) => void;
}) {
const { t } = useI18n();
const panelRef = useRef<HTMLElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const programmaticScrollRef = useRef(false);
const focusScrollGuardRef = useRef(false);
const focusScrollGuardFrameRef = useRef<number | null>(null);
const focusScrollGuardFallbackRef = useRef<number | null>(null);
const [tooltip, setTooltip] = useState<SessionTimelineTooltip | null>(null);
const currentIndex =
currentRange !== null
? currentRange.currentIndex
: entries.findIndex((entry) => entry.id === currentTurnId);
const hideTooltip = useCallback(() => setTooltip(null), []);
const handleViewportScroll = useCallback(() => {
if (programmaticScrollRef.current || focusScrollGuardRef.current) return;
hideTooltip();
}, [hideTooltip]);
const buildTooltip = useCallback(
(entry: SessionTimelineEntry, el: HTMLElement) => {
const panel = panelRef.current;
if (!panel) return null;
const computedStyle = getComputedStyle(panel);
const rect = el.getBoundingClientRect();
return {
entry,
top: rect.top + rect.height / 2,
left: rect.right + 8,
clamped: false,
themeVars: Object.fromEntries(
SESSION_TIMELINE_TOOLTIP_THEME_VARS.map((name) => [
name,
computedStyle.getPropertyValue(name),
]),
) as CSSProperties,
};
},
[],
);
const findTooltipAnchor = useCallback((entry: SessionTimelineEntry) => {
const viewport = viewportRef.current;
if (!viewport) return null;
const item = Array.from(
viewport.querySelectorAll<HTMLElement>(
'[data-testid="session-timeline-entry"]',
),
).find((node) => node.getAttribute('data-turn-id') === entry.id);
return item?.querySelector<HTMLButtonElement>('button') ?? null;
}, []);
const isTooltipAnchorVisible = useCallback((anchor: HTMLElement) => {
const viewport = viewportRef.current;
if (!viewport) return false;
const viewportRect = viewport.getBoundingClientRect();
const anchorRect = anchor.getBoundingClientRect();
return (
anchorRect.bottom >= viewportRect.top &&
anchorRect.top <= viewportRect.bottom
);
}, []);
const syncTooltip = useCallback(() => {
setTooltip((current) => {
if (!current) return null;
const anchor = findTooltipAnchor(current.entry);
if (!anchor || !isTooltipAnchorVisible(anchor)) return null;
return buildTooltip(current.entry, anchor);
});
}, [buildTooltip, findTooltipAnchor, isTooltipAnchorVisible]);
const showTooltip = useCallback(
(entry: SessionTimelineEntry, el: HTMLElement) => {
setTooltip(buildTooltip(entry, el));
},
[buildTooltip],
);
const guardFocusScroll = useCallback(() => {
if (typeof window === 'undefined') return;
if (focusScrollGuardFrameRef.current !== null) {
window.cancelAnimationFrame(focusScrollGuardFrameRef.current);
}
if (focusScrollGuardFallbackRef.current !== null) {
window.clearTimeout(focusScrollGuardFallbackRef.current);
}
focusScrollGuardRef.current = true;
focusScrollGuardFrameRef.current = window.requestAnimationFrame(() => {
focusScrollGuardFrameRef.current = null;
focusScrollGuardRef.current = false;
syncTooltip();
});
focusScrollGuardFallbackRef.current = window.setTimeout(() => {
focusScrollGuardFallbackRef.current = null;
focusScrollGuardRef.current = false;
}, 100);
}, [syncTooltip]);
useLayoutEffect(() => {
if (hidden) return;
const viewport = viewportRef.current;
if (!viewport || currentIndex < 0) return;
const item = viewport.querySelector<HTMLElement>(
`[data-timeline-index="${currentIndex}"]`,
);
if (!item) return;
const itemCenter = item.offsetTop + item.offsetHeight / 2;
const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
const nextScrollTop = Math.max(
0,
Math.min(itemCenter - viewport.clientHeight / 2, maxScrollTop),
);
if (viewport.scrollTop === nextScrollTop) return;
programmaticScrollRef.current = true;
viewport.scrollTop = nextScrollTop;
const frame = window.requestAnimationFrame(() => {
programmaticScrollRef.current = false;
syncTooltip();
});
const fallback = window.setTimeout(() => {
programmaticScrollRef.current = false;
}, 100);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(fallback);
programmaticScrollRef.current = false;
};
}, [currentIndex, hidden, syncTooltip]);
useLayoutEffect(() => {
if (!tooltip || typeof window === 'undefined') return;
window.addEventListener('resize', syncTooltip);
return () => {
window.removeEventListener('resize', syncTooltip);
};
}, [syncTooltip, tooltip]);
useLayoutEffect(
() => () => {
if (focusScrollGuardFrameRef.current !== null) {
window.cancelAnimationFrame(focusScrollGuardFrameRef.current);
}
if (focusScrollGuardFallbackRef.current !== null) {
window.clearTimeout(focusScrollGuardFallbackRef.current);
}
focusScrollGuardRef.current = false;
},
[],
);
useLayoutEffect(() => {
if (!tooltip || tooltip.clamped) return;
const panel = panelRef.current;
const tooltipEl = tooltipRef.current;
if (!panel || !tooltipEl || typeof window === 'undefined') return;
const rect = tooltipEl.getBoundingClientRect();
const margin = 12;
let nextTop = tooltip.top;
if (rect.top < margin) {
nextTop += margin - rect.top;
} else if (rect.bottom > window.innerHeight - margin) {
nextTop -= rect.bottom - (window.innerHeight - margin);
}
if (nextTop === tooltip.top) return;
setTooltip((current) =>
current?.entry.id === tooltip.entry.id
? { ...current, top: nextTop, clamped: true }
: current,
);
}, [tooltip]);
if (hidden || entries.length === 0) return null;
return (
<div className={styles.sessionTimelineLayer} aria-hidden="false">
<nav
ref={panelRef}
className={styles.sessionTimelinePanel}
aria-label={t('timeline.sessionTimeline')}
data-testid="session-timeline"
onMouseLeave={hideTooltip}
>
<ol className={styles.sessionTimelineList}>
{entries.map((entry, index) => {
const isInCurrentRange =
currentRange !== null &&
index >= currentRange.startIndex &&
index <= currentRange.endIndex;
const isCurrent =
currentRange !== null
? index === currentRange.currentIndex
: entry.id === currentTurnId;
const nodeKinds = entry.nodeKinds.join(',');
const ariaLabel = [
`${t('timeline.turnPrefix', { index: index + 1 })}: ${entry.label}`,
isCurrent ? t('timeline.currentTurn') : null,
]
.filter(Boolean)
.join('. ');
return (
<li
key={entry.id}
className={styles.sessionTimelineItem}
data-testid="session-timeline-entry"
data-turn-id={entry.id}
data-node-kinds={nodeKinds}
data-in-current-range={isInCurrentRange ? 'true' : undefined}
>
<button
type="button"
className={joinClassNames(
styles.sessionTimelineButton,
isInCurrentRange
? styles.sessionTimelineButtonInRange
: undefined,
isCurrent ? styles.sessionTimelineButtonCurrent : undefined,
)}
aria-current={isCurrent ? 'step' : undefined}
aria-label={ariaLabel}
onClick={() => onSelect(entry.id)}
<div
ref={viewportRef}
className={styles.sessionTimelineViewport}
data-testid="session-timeline-viewport"
onScroll={handleViewportScroll}
>
<ol className={styles.sessionTimelineList}>
{entries.map((entry, index) => {
const isInCurrentRange =
currentRange !== null &&
index >= currentRange.startIndex &&
index <= currentRange.endIndex;
const isCurrent =
currentRange !== null
? index === currentRange.currentIndex
: entry.id === currentTurnId;
const nodeKinds = entry.nodeKinds.join(',');
const ariaLabel = [
`${t('timeline.turnPrefix', { index: index + 1 })}: ${entry.label}`,
isCurrent ? t('timeline.currentTurn') : null,
]
.filter(Boolean)
.join('. ');
const revealTooltip = (
event:
| ReactMouseEvent<HTMLButtonElement>
| ReactFocusEvent<HTMLButtonElement>,
) => showTooltip(entry, event.currentTarget);
const revealFocusedTooltip = (
event: ReactFocusEvent<HTMLButtonElement>,
) => {
guardFocusScroll();
showTooltip(entry, event.currentTarget);
};
const describedByTooltip = tooltip?.entry.id === entry.id;
return (
<li
key={entry.id}
className={styles.sessionTimelineItem}
data-testid="session-timeline-entry"
data-turn-id={entry.id}
data-timeline-index={index}
data-node-kinds={nodeKinds}
data-in-current-range={isInCurrentRange ? 'true' : undefined}
>
<span className={styles.sessionTimelineTick} />
<span
className={styles.sessionTimelineDetails}
data-testid="session-timeline-detail"
data-title={entry.label}
data-detail={entry.detail}
data-scheduled-task={
entry.isScheduledTask ? 'true' : undefined
<button
type="button"
className={joinClassNames(
styles.sessionTimelineButton,
isInCurrentRange
? styles.sessionTimelineButtonInRange
: undefined,
isCurrent
? styles.sessionTimelineButtonCurrent
: undefined,
)}
aria-current={isCurrent ? 'step' : undefined}
aria-describedby={
describedByTooltip
? SESSION_TIMELINE_TOOLTIP_ID
: undefined
}
aria-hidden="true"
aria-label={ariaLabel}
onClick={() => onSelect(entry.id)}
onFocus={revealFocusedTooltip}
onBlur={hideTooltip}
onMouseEnter={revealTooltip}
onMouseLeave={hideTooltip}
>
<span className={styles.sessionTimelineDetailsTitle}>
{entry.isScheduledTask && <TimelineClockIcon />}
<span className={styles.sessionTimelineDetailsTitleText}>
{entry.label}
</span>
</span>
<span className={styles.sessionTimelineDetailsDetail}>
{entry.detail}
</span>
</span>
</button>
</li>
);
})}
</ol>
<span className={styles.sessionTimelineTick} />
</button>
</li>
);
})}
</ol>
</div>
{tooltip &&
typeof document !== 'undefined' &&
createPortal(
<div
ref={tooltipRef}
className={styles.sessionTimelineDetails}
id={SESSION_TIMELINE_TOOLTIP_ID}
data-testid="session-timeline-detail"
data-title={tooltip.entry.label}
data-detail={tooltip.entry.detail}
data-scheduled-task={
tooltip.entry.isScheduledTask ? 'true' : undefined
}
role="tooltip"
style={{
...tooltip.themeVars,
top: tooltip.top,
left: tooltip.left,
}}
>
<span className={styles.sessionTimelineDetailsTitle}>
{tooltip.entry.isScheduledTask && <TimelineClockIcon />}
<span className={styles.sessionTimelineDetailsTitleText}>
{tooltip.entry.label}
</span>
</span>
<span className={styles.sessionTimelineDetailsDetail}>
{tooltip.entry.detail}
</span>
</div>,
document.body,
)}
</nav>
</div>
);