mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
2a1807f08d
commit
0907edb909
8 changed files with 812 additions and 153 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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]` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -960,7 +960,9 @@ export const useSlashCommandProcessor = (
|
|||
return {
|
||||
type: 'submit_prompt',
|
||||
content: mergedContent,
|
||||
...(firstModelOverride ? { modelOverride: firstModelOverride } : {}),
|
||||
...(firstModelOverride
|
||||
? { modelOverride: firstModelOverride }
|
||||
: {}),
|
||||
...(onCompleteCallbacks.length
|
||||
? {
|
||||
onComplete: async () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue