mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605c3f3832 | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed | ||
|
|
a4b15455b7 | ||
|
|
bd9af256bd | ||
|
|
b1a638482d | ||
|
|
12aaded6c5 | ||
|
|
0614e96ee0 | ||
|
|
e135e9c101 | ||
|
|
335a11eb82 | ||
|
|
17562fcb66 | ||
|
|
5805b776c2 | ||
|
|
4400445edb | ||
|
|
337e81a9c6 | ||
|
|
b923cde6cf | ||
|
|
058375dac8 | ||
|
|
eb051ea81e | ||
|
|
1839de5b1d | ||
|
|
ec6393f31f | ||
|
|
b5e51cf839 | ||
|
|
b0f054de90 | ||
|
|
e8108d9092 | ||
|
|
9eaf7ad0c1 | ||
|
|
be0acb2935 | ||
|
|
196dc3acd3 | ||
|
|
e867773932 | ||
|
|
bfe38d92f6 | ||
|
|
9551b2cc11 | ||
|
|
6f62125ee0 | ||
|
|
8bc5f47b1c | ||
|
|
e1b01df905 | ||
|
|
7321d8a488 | ||
|
|
283a985fb6 | ||
|
|
8d03ccfbf0 | ||
|
|
acb3a8f207 | ||
|
|
a2b040869d | ||
|
|
99e4b53aa0 | ||
|
|
750cb82fbb | ||
|
|
9246813fd2 | ||
|
|
06f0522258 | ||
|
|
9b92b2e25c | ||
|
|
a310444c67 | ||
|
|
c364b16d40 | ||
|
|
dc7bc8db12 | ||
|
|
40f0393064 | ||
|
|
166aabd57b | ||
|
|
b852ae920b | ||
|
|
b3693b79d1 | ||
|
|
0458988825 | ||
|
|
4bd31e1a2e | ||
|
|
1939ee79ed | ||
|
|
8d9e7d3ad3 | ||
|
|
114389383d | ||
|
|
804335bc00 | ||
|
|
e2fd5a9c57 | ||
|
|
b412374f0c | ||
|
|
a3f75aa9e4 | ||
|
|
46b1284177 | ||
|
|
a5dba3c992 | ||
|
|
dd0350bc2b | ||
|
|
cfb18f16d4 | ||
|
|
7f9b65d464 | ||
|
|
7e66657bf0 | ||
|
|
446749102e | ||
|
|
e61009d5fb | ||
|
|
ee0d901efb | ||
|
|
d5513472ed | ||
|
|
1228c01a10 | ||
|
|
563c014dc1 | ||
|
|
01f9bc8291 | ||
|
|
d0728cd1ed | ||
|
|
0ba4d2526e | ||
|
|
7868f5caf6 | ||
|
|
0147edaaac | ||
|
|
bd78e5efa4 | ||
|
|
e81d5d02b1 | ||
|
|
b63a325be3 | ||
|
|
6ec6633425 | ||
|
|
b26b9234c0 | ||
|
|
aec01ea605 |
@@ -93,3 +93,6 @@ app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
# Project-local Serena configuration and memories.
|
||||
.serena/
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/menu-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
"packages/app/src/components/host-status-dot.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
|
||||
@@ -54,7 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
|
||||
@@ -151,11 +151,12 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura
|
||||
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
|
||||
close retains the runtime for cleanup and blocks replacement through that close operation.
|
||||
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
|
||||
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
|
||||
releases the process before returning. It never loads, resumes, or unarchives a native thread,
|
||||
including legacy records whose native archive failed. The retained history session contains only
|
||||
the read results. Interactive resume remains responsible for repairing a provider session archived
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
|
||||
history session contains only the read results and must release its provider process once replay
|
||||
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
|
||||
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
|
||||
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
|
||||
before returning. Interactive resume remains responsible for repairing a provider session archived
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
@@ -211,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
||||
guards already prevent throttling from causing a false stall.
|
||||
|
||||
### Desktop Linux hidden-window fallback
|
||||
|
||||
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
|
||||
forever without a single compositor frame: the renderer loads and runs, but
|
||||
`ready-to-show` never fires, so the window is never shown and the app looks like
|
||||
it did not start while every process is alive. `showWindowWhenReady`
|
||||
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
|
||||
the document finishes loading if the event has not arrived. The fallback is
|
||||
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
|
||||
|
||||
Run the startup regression on Linux with an isolated display:
|
||||
|
||||
```bash
|
||||
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
|
||||
@@ -7,6 +7,7 @@ contracts.
|
||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||
| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
@@ -22,6 +23,24 @@ can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
## Workspace sheet
|
||||
|
||||
On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab
|
||||
switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
|
||||
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
|
||||
sheet and tab, so callers open targets the same way on every layout. The sheet holds one ordinary
|
||||
`WorkspaceTab`, which is not persisted.
|
||||
|
||||
The sheet is modal and covers the whole compact column, header included. It renders in the
|
||||
workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and
|
||||
every workspace provider an ordinary pane has, and a portal drops them
|
||||
([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
|
||||
mounted view, so the move would drop it. Widening the layout is the known exception: it still moves
|
||||
the sheet's tab into a pane, and edits the autosave has not started writing are lost.
|
||||
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
@@ -62,7 +81,9 @@ lifecycle.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
|
||||
mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
|
||||
pane, so callers never check whether splits render.
|
||||
|
||||
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
|
||||
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
||||
|
||||
@@ -304,7 +304,9 @@ track phase.
|
||||
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
||||
includes native centered dialogs: separate React Native Modals cannot present a root-owned
|
||||
sibling dialog over an existing iOS presenter.
|
||||
sibling dialog over an existing iOS presenter. The one exception is the
|
||||
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
|
||||
whole workspace panel.
|
||||
|
||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||
|
||||
@@ -21,6 +21,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
@@ -61,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
|
||||
|
||||
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
|
||||
|
||||
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
@@ -145,6 +146,7 @@ export default {
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
withAndroidOptionalCamera,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Load the copies the Expo CLI uses for prebuild, not older hoisted ones.
|
||||
const requireFromExpo = createRequire(require.resolve("expo/package.json"));
|
||||
const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json"));
|
||||
const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config");
|
||||
const { compileModsAsync } = requireFromExpoCli(
|
||||
"@expo/config-plugins/build/plugins/mod-compiler.js",
|
||||
);
|
||||
|
||||
interface ManifestEntry {
|
||||
$: Record<string, string>;
|
||||
}
|
||||
|
||||
const originalFdroidFlag = process.env.PASEO_FDROID_BUILD;
|
||||
|
||||
async function resolveAndroidManifest() {
|
||||
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] });
|
||||
await compileModsAsync(exp, {
|
||||
projectRoot: __dirname,
|
||||
introspect: true,
|
||||
platforms: ["android"],
|
||||
assertMissingModProviders: false,
|
||||
});
|
||||
return exp._internal.modResults.android.manifest.manifest;
|
||||
}
|
||||
|
||||
describe("Android app config", () => {
|
||||
afterEach(() => {
|
||||
if (originalFdroidFlag === undefined) {
|
||||
delete process.env.PASEO_FDROID_BUILD;
|
||||
} else {
|
||||
process.env.PASEO_FDROID_BUILD = originalFdroidFlag;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Google Play", "0"],
|
||||
["F-Droid", "1"],
|
||||
])(
|
||||
"%s build installs on devices without a camera",
|
||||
async (_build, fdroidFlag) => {
|
||||
process.env.PASEO_FDROID_BUILD = fdroidFlag;
|
||||
const manifest = await resolveAndroidManifest();
|
||||
const permissions = (manifest["uses-permission"] ?? []).map(
|
||||
(entry: ManifestEntry) => entry.$["android:name"],
|
||||
);
|
||||
const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$);
|
||||
|
||||
expect(permissions).toContain("android.permission.CAMERA");
|
||||
expect(features).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ "android:name": "android.hardware.camera", "android:required": "false" },
|
||||
{ "android:name": "android.hardware.camera.autofocus", "android:required": "false" },
|
||||
]),
|
||||
);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -223,6 +223,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectEmptyChanges(page: Page) {
|
||||
await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function editReadme(repoPath: string, text: string) {
|
||||
await appendFile(path.join(repoPath, "README.md"), text);
|
||||
}
|
||||
|
||||
async function expectReadmeChanges(page: Page, additions: number) {
|
||||
await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(changesTree(page)).toContainText(`+${additions}`);
|
||||
}
|
||||
|
||||
async function openEmptyDiff(page: Page) {
|
||||
await changesTree(page).getByTestId("changes-options-menu").click();
|
||||
await page.getByTestId("changes-open-tab").click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectReadmeDiff(page: Page, additions: number) {
|
||||
await expect(
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openDiffFromNewTabMenu(page: Page) {
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
|
||||
await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshDiff(page: Page) {
|
||||
await page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("changes-refresh")
|
||||
.click();
|
||||
}
|
||||
|
||||
async function reloadWorkspace(page: Page) {
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test("Changes follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeChanges(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeChanges(page, 2);
|
||||
});
|
||||
|
||||
test("the full diff follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
await openEmptyDiff(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
});
|
||||
|
||||
test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openDiffFromNewTabMenu(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
|
||||
await refreshDiff(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await reloadWorkspace(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await editReadme(workspace.repoPath, "third edit\n");
|
||||
await expectReadmeDiff(page, 3);
|
||||
});
|
||||
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
|
||||
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
expectReadingStreamedMarkdown,
|
||||
} from "../support/helpers/chat-outline";
|
||||
import { createCreationScenario } from "../support/helpers/creation";
|
||||
import {
|
||||
attachPromptTimelineEvidence,
|
||||
expectPromptsDrawnByPlugin,
|
||||
expectVirtualizedPluginPromptVisible,
|
||||
withPromptTimelinePlugin,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
import {
|
||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||
expectTimelinePromptNotMounted,
|
||||
@@ -105,6 +111,60 @@ test.describe("desktop chat outline", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("jumps to prompts a timeline plugin draws", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectChatOutlinePrompts(page, 80);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
await expectTimelinePromptNotMounted(page, agent.prompts[39]);
|
||||
|
||||
// Unloaded, then loaded, then near the newest edge.
|
||||
await clickChatOutlineRowEdge(page, 40);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
|
||||
await attachPromptTimelineEvidence(page, test.info());
|
||||
await clickChatOutlineRowEdge(page, 45);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
|
||||
await clickChatOutlineRowEdge(page, 78);
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
const prompt = agent.prompts[29]!;
|
||||
try {
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await page.setViewportSize(WIDE_VIEWPORT);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectChatOutlinePrompts(page, 80);
|
||||
await scrollThroughOlderHistoryPages(page, 2, history);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptNotMounted(page, prompt);
|
||||
const requestsBeforeJump = history.requestCount();
|
||||
|
||||
await clickChatOutlineRowEdge(page, 30);
|
||||
await expectTimelinePromptLandedBelowTop(page, prompt);
|
||||
await expectVirtualizedPluginPromptVisible(page, prompt);
|
||||
await attachPromptTimelineEvidence(page, test.info());
|
||||
expect(history.requestCount()).toBe(requestsBeforeJump);
|
||||
}, [prompt]);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("with the whole conversation loaded", () => {
|
||||
let agent: LongTimelineAgent;
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const labels = {
|
||||
long: {
|
||||
title: "workspace-with-a-very-long-release-and-investigation-name",
|
||||
project: "project-with-a-very-long-customer-integration-name",
|
||||
},
|
||||
short: { title: "main", project: "Paseo" },
|
||||
};
|
||||
|
||||
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
|
||||
try {
|
||||
await workspace.client.renameProject(workspace.projectId, names.project);
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Header layout check",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
|
||||
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
|
||||
await expect(
|
||||
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
return workspace;
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const project = page.getByTestId("workspace-header-subtitle");
|
||||
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
|
||||
const trigger = page.getByRole("button", { name: /Switch tabs/ });
|
||||
const measure = async () => ({
|
||||
title: await title.boundingBox(),
|
||||
project: await project.boundingBox(),
|
||||
badge: await badge.boundingBox(),
|
||||
icon: await badge.locator("svg").boundingBox(),
|
||||
trigger: await trigger.boundingBox(),
|
||||
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
|
||||
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
|
||||
});
|
||||
await page.screenshot({ path: info.outputPath("header.png") });
|
||||
await expect(async () => {
|
||||
const bounds = await measure();
|
||||
expect(bounds.title).not.toBeNull();
|
||||
expect(bounds.project).not.toBeNull();
|
||||
expect(bounds.badge).not.toBeNull();
|
||||
expect(bounds.icon).not.toBeNull();
|
||||
expect(bounds.trigger).not.toBeNull();
|
||||
// Approved geometry: 12px outside margin, 46px leading island, 8px title gap.
|
||||
expect(bounds.title!.x).toBe(66);
|
||||
expect(bounds.project!.x).toBe(bounds.title!.x);
|
||||
expect(bounds.trigger!.width).toBe(32);
|
||||
expect(bounds.trigger!.height).toBe(32);
|
||||
// The action frame starts 7px inside its island; preserve the 8px gap before it.
|
||||
const contentRight = bounds.trigger!.x - 15;
|
||||
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1);
|
||||
}
|
||||
expect(bounds.project!.width).toBeGreaterThan(20);
|
||||
expect(bounds.icon!.width).toBe(12);
|
||||
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
|
||||
expect(bounds.titleTruncated).toBe(long);
|
||||
expect(bounds.projectTruncated).toBe(long);
|
||||
}).toPass({ timeout: 5_000 });
|
||||
await info.attach("header-bounds", {
|
||||
body: JSON.stringify(await measure(), null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [320, 390]) {
|
||||
for (const kind of ["long", "short"] as const) {
|
||||
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
const workspace = await openNamedWorkspace(page, labels[kind]);
|
||||
try {
|
||||
await expectHeaderContentsFit(page, info, kind === "long");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
|
||||
import {
|
||||
closeWorkspaceSheet,
|
||||
expectOpenTabCount,
|
||||
openWorkspaceSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
expectWorkspaceSheetAtSnap,
|
||||
replaceWorkspaceSheetFileText,
|
||||
selectTabFromSwitcher,
|
||||
tapWorkspaceSheetBackdrop,
|
||||
workspaceSheet,
|
||||
} from "../support/helpers/workspace-sheet";
|
||||
|
||||
const FILE_LINK = "linked.ts:3";
|
||||
|
||||
function promptMentioning(target: string): string {
|
||||
return [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-workspace-sheet-",
|
||||
title: "Sheet parent",
|
||||
initialPrompt: promptMentioning(FILE_LINK),
|
||||
});
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const linkedPath = path.join(workspace.cwd, "linked.ts");
|
||||
await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
|
||||
const child = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||
);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
await test.step("a chat file link opens the file in a sheet", async () => {
|
||||
const fileLink = page.getByText(FILE_LINK, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("three")).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, "linked.ts");
|
||||
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
await closeWorkspaceSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet without its composer", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const sheet = workspaceSheet(page, child.title);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, child.title);
|
||||
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab", async () => {
|
||||
await openWorkspaceSheetAsTab(page, child.title);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
|
||||
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
|
||||
await selectTabFromSwitcher(page, "Sheet parent");
|
||||
await page.getByText(FILE_LINK, { exact: true }).click();
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
// Hold the autosave so the edit stays unsaved.
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
await expectDeclinedDiscardPrompt(page, () =>
|
||||
sheet.getByRole("button", { name: "Close" }).click(),
|
||||
);
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||
import {
|
||||
fillComposerDraft,
|
||||
expectComposerDraft,
|
||||
attachFileFromMenu,
|
||||
} from "../support/helpers/composer";
|
||||
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
@@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) {
|
||||
await composerChangesPill(page).click();
|
||||
}
|
||||
|
||||
async function seedChangedAgent(repoPrefix: string) {
|
||||
async function seedChangedAgent(
|
||||
repoPrefix: string,
|
||||
featureValues?: Record<string, unknown>,
|
||||
files: Array<{ path: string; content: string }> = [],
|
||||
) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix,
|
||||
featureValues,
|
||||
title: "Composer diff stat",
|
||||
repo: {
|
||||
withRemote: true,
|
||||
// Exclude the local remote before the daemon starts observing this checkout.
|
||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
|
||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -156,3 +168,691 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function tapFirstAddedDiffLine(
|
||||
page: Page,
|
||||
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||
) {
|
||||
const body = surface.getByTestId("diff-file-0-body");
|
||||
await expect(body).toBeVisible({ timeout: 30000 });
|
||||
const bounds = await body.boundingBox();
|
||||
if (!bounds) throw new Error("Expanded diff body has no bounds");
|
||||
const fontSize = await surface
|
||||
.getByTestId("git-diff-canvas")
|
||||
.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
|
||||
await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5);
|
||||
}
|
||||
|
||||
async function openReviewDiff(page: Page, workspace: Awaited<ReturnType<typeof seedChangedAgent>>) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 });
|
||||
if (await page.getByTestId("workspace-tab-working_diff").count()) {
|
||||
await selectDiffTab(page);
|
||||
} else {
|
||||
await revealComposerChangesInExplorer(page);
|
||||
await openComposerDiff(page);
|
||||
}
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
|
||||
async function saveReviewComment(page: Page, body: string) {
|
||||
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
|
||||
await page.getByRole("button", { name: "Save review comment", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function cancelReviewComment(page: Page) {
|
||||
await page.getByRole("button", { name: "Cancel review comment", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("review comments", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("compact-review-comment-");
|
||||
try {
|
||||
await test.step("Open a compact sheet with reachable input and actions", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await expectCompactCommentSheet(page);
|
||||
});
|
||||
await test.step("Save, discard an edit, then save an updated comment", async () => {
|
||||
await saveReviewComment(page, " Please simplify this. ");
|
||||
await expectSavedReview(page, "Please simplify this.");
|
||||
await editSavedReview(page, "Please simplify this.");
|
||||
await fillReviewComment(page, "Discard this edit");
|
||||
await cancelReviewComment(page);
|
||||
await expectSavedReview(page, "Please simplify this.");
|
||||
await editSavedReview(page, "Please simplify this.");
|
||||
await saveReviewComment(page, "Updated comment");
|
||||
await expectSavedReview(page, "Updated comment");
|
||||
});
|
||||
await test.step("Discard a new comment and retain the saved one after reload", async () => {
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await fillReviewComment(page, "Discard new comment");
|
||||
await cancelReviewComment(page);
|
||||
await expectReviewAbsent(page, "Discard new comment");
|
||||
await reloadReview(page);
|
||||
await expectSavedReview(page, "Updated comment");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("wide review comments retain inline editing", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("wide-review-comment-");
|
||||
try {
|
||||
await test.step("Save and cancel an inline edit on a wide screen", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await expectInlineCommentEditor(page);
|
||||
await saveReviewComment(page, "Wide comment");
|
||||
await expectSavedReview(page, "Wide comment");
|
||||
await editSavedReview(page, "Wide comment");
|
||||
await expectInlineCommentEditor(page);
|
||||
await cancelReviewComment(page);
|
||||
await expectSavedReview(page, "Wide comment");
|
||||
});
|
||||
await test.step("The existing composer still sends the review", async () => {
|
||||
await sendReviewThroughComposer(page, workspace.agentId);
|
||||
await selectDiffTab(page);
|
||||
await expectReviewAbsent(page, "Wide comment");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function sendReviewThroughComposer(page: Page, agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).click();
|
||||
await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review");
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function addSavedReview(
|
||||
page: Page,
|
||||
body: string,
|
||||
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||
) {
|
||||
await tapFirstAddedDiffLine(page, surface);
|
||||
await saveReviewComment(page, body);
|
||||
await expectSavedReview(page, body, surface);
|
||||
}
|
||||
|
||||
async function expectSavedReview(
|
||||
page: Page,
|
||||
body: string,
|
||||
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||
) {
|
||||
await expect(surface.getByText(body, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function editSavedReview(page: Page, expectedBody: string) {
|
||||
await page
|
||||
.getByTestId("working-diff-panel")
|
||||
.getByRole("button", { name: "Edit review comment", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody);
|
||||
}
|
||||
|
||||
async function sendFeedback(page: Page, count: number) {
|
||||
await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectFeedbackSent(page: Page, recipient: string) {
|
||||
await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("send review feedback", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-single-");
|
||||
try {
|
||||
await test.step("Save a review without changing the chat draft", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await selectAgentTab(page, workspace.agentId);
|
||||
await fillComposerDraft(page, "Keep this thought");
|
||||
await attachFileFromMenu(page, {
|
||||
name: "unrelated.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("Unrelated draft attachment"),
|
||||
});
|
||||
await selectDiffTab(page);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Please simplify this.");
|
||||
});
|
||||
await test.step("Send directly and stay on the diff", async () => {
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewAbsent(page, "Please simplify this.");
|
||||
await captureFeedback(page, "single");
|
||||
});
|
||||
await test.step("Return to the untouched composer draft", async () => {
|
||||
await showWideLayout(page);
|
||||
await selectAgentTab(page, workspace.agentId);
|
||||
await expectComposerDraft(page, "Keep this thought");
|
||||
await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
test("feedback pending ownership follows the draft across Diff and compact Changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-shared-pending-");
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
try {
|
||||
await test.step("Send feedback from the Diff tab and hold its real request", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "One draft across surfaces");
|
||||
await sendFeedback(page, 1);
|
||||
await gate.waitForRequest();
|
||||
});
|
||||
await test.step("The compact Changes surface observes the same pending operation", async () => {
|
||||
await revealCompactChanges(page);
|
||||
await expect(
|
||||
compactReviewSurface(page).getByRole("button", {
|
||||
name: "Sending feedback (1)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeDisabled();
|
||||
expect(gate.requestCount()).toBe(1);
|
||||
gate.accept();
|
||||
await expect(
|
||||
compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an unsaved review survives presentation changes and acknowledgement before Save", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-editor-ownership-");
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
try {
|
||||
await test.step("Carry unsaved inline text into the compact sheet and back", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await tapFirstAddedDiffLine(page);
|
||||
await fillReviewComment(page, "Body across presentations");
|
||||
await showCompactLayout(page);
|
||||
await expectReviewEditorBody(page, "Body across presentations");
|
||||
await showWideLayout(page);
|
||||
await expectReviewEditorBody(page, "Body across presentations");
|
||||
await saveReviewComment(page, "Body across presentations");
|
||||
});
|
||||
await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => {
|
||||
await sendFeedback(page, 1);
|
||||
await gate.waitForRequest();
|
||||
await editSavedReview(page, "Body across presentations");
|
||||
await fillReviewComment(page, "Keep this unsaved edit");
|
||||
gate.accept();
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewEditorBody(page, "Keep this unsaved edit");
|
||||
await showCompactLayout(page);
|
||||
await expectReviewEditorBody(page, "Keep this unsaved edit");
|
||||
await saveReviewComment(page, "Keep this unsaved edit");
|
||||
await expectSavedReview(page, "Keep this unsaved edit");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("saved feedback preserves navigation across multiple diff files", async ({ page }) => {
|
||||
const workspace = await seedMultiFileReview();
|
||||
try {
|
||||
await test.step("Save compact feedback and retain both floating actions", async () => {
|
||||
await openCompactReviewExplorer(page, workspace);
|
||||
await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page));
|
||||
await expectReviewNavigationReachable(page);
|
||||
});
|
||||
await test.step("Jump to another file without covering the feedback action", async () => {
|
||||
await jumpToReviewFile(page, "z-last.ts");
|
||||
await scrollReviewToLastLine(page);
|
||||
await expectReviewNavigationReachable(page);
|
||||
await captureFeedback(page, "navigation-compact");
|
||||
});
|
||||
await test.step("Wide file navigation and feedback remain reachable", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await navigateWideReviewFile(page, "README.md");
|
||||
await expectFeedbackReachable(page);
|
||||
await expectSavedReview(page, "Keep file navigation available");
|
||||
await captureFeedback(page, "navigation-wide");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("feedback sends only matching comments and preserves orphaned comments", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMultiFileReview();
|
||||
try {
|
||||
await test.step("A saved comment whose diff disappears has no sendable context", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Retain this orphan");
|
||||
await setReadmeDiff(workspace, false);
|
||||
await expectUnmatchedFeedback(page);
|
||||
});
|
||||
await test.step("Send the matching subset with an accurate count", async () => {
|
||||
await addSavedReview(page, "Send this matching comment");
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewAbsent(page, "Send this matching comment");
|
||||
await expectUnmatchedFeedback(page);
|
||||
});
|
||||
await test.step("Restoring the missing diff reveals the unsent comment", async () => {
|
||||
await setReadmeDiff(workspace, true);
|
||||
await expectSavedReview(page, "Retain this orphan");
|
||||
await expectFeedbackReachable(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-zero-");
|
||||
try {
|
||||
await test.step("Close the agent tab while keeping the review", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await addSavedReview(page, "Keep until an agent is opened");
|
||||
await closeAgentTab(page, workspace.agentId);
|
||||
});
|
||||
await test.step("The disabled action explains why in wide and compact layouts", async () => {
|
||||
await expectFeedbackUnavailable(page);
|
||||
await captureFeedback(page, "zero-wide");
|
||||
await showCompactLayout(page);
|
||||
await expectFeedbackUnavailable(page);
|
||||
await expectSavedReview(page, "Keep until an agent is opened");
|
||||
await captureFeedback(page, "zero-compact");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
||||
const other = await seedChangedAgent("review-feedback-other-");
|
||||
try {
|
||||
const second = await createReviewRecipient(workspace, "Second reviewer");
|
||||
const historical = await createReviewRecipient(workspace, "Closed historical agent");
|
||||
await test.step("Open two local tabs and a tab in another workspace", async () => {
|
||||
await openReviewDiff(page, other);
|
||||
await addSavedReview(page, "Other workspace review");
|
||||
await openReviewDiff(page, workspace);
|
||||
await closeAgentTab(page, historical.id);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id });
|
||||
await selectDiffTab(page);
|
||||
await addSavedReview(page, "Review for second agent");
|
||||
});
|
||||
await test.step("The wide menu includes only local open agents", async () => {
|
||||
await captureWideFeedbackAppearance(page);
|
||||
});
|
||||
await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => {
|
||||
await openFeedbackWithKeyboard(page);
|
||||
await dismissRecipientMenu(page);
|
||||
await expectFeedbackFocused(page);
|
||||
await reopenFeedbackWithKeyboard(page);
|
||||
await dismissRecipientMenu(page);
|
||||
await expectFeedbackFocused(page);
|
||||
});
|
||||
await test.step("Choose the second agent in the compact menu and stay on the diff", async () => {
|
||||
await showCompactLayout(page);
|
||||
await captureFeedback(page, "cta-compact");
|
||||
await sendFeedback(page, 1);
|
||||
await expectRecipientMenu(page);
|
||||
await captureFeedback(page, "menu-compact");
|
||||
await chooseFeedbackRecipient(page, "Second reviewer");
|
||||
await expectFeedbackSent(page, "Second reviewer");
|
||||
await expectReviewAbsent(page, "Review for second agent");
|
||||
});
|
||||
await test.step("The other workspace's saved review remains", async () => {
|
||||
await openReviewDiff(page, other);
|
||||
await expectSavedReview(page, "Other workspace review");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await other.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("failed feedback remains editable and retries through the real provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 });
|
||||
try {
|
||||
await test.step("A provider rejection retains the saved review", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Initial feedback");
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackFailure(page);
|
||||
await expectSavedReview(page, "Initial feedback");
|
||||
await captureFeedback(page, "failure");
|
||||
});
|
||||
await test.step("Edit the retained feedback and retry successfully", async () => {
|
||||
await editSavedReview(page, "Initial feedback");
|
||||
await saveReviewComment(page, "Corrected feedback");
|
||||
await sendFeedback(page, 1);
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectReviewAbsent(page, "Corrected feedback");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pending feedback blocks repeat submission and preserves edits and additions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedChangedAgent("review-feedback-pending-");
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
try {
|
||||
await test.step("Hold the real request while feedback is pending", async () => {
|
||||
await openReviewDiff(page, workspace);
|
||||
await showCompactLayout(page);
|
||||
await addSavedReview(page, "Sent version");
|
||||
await sendFeedback(page, 1);
|
||||
const request = await gate.waitForRequest();
|
||||
expect(request.agentId).toBe(workspace.agentId);
|
||||
expect(request.text).toBe("Please address this code review.");
|
||||
expect(request.attachments).toMatchObject([
|
||||
{
|
||||
type: "review",
|
||||
cwd: workspace.cwd,
|
||||
comments: [
|
||||
{
|
||||
filePath: "README.md",
|
||||
lineNumber: 2,
|
||||
body: "Sent version",
|
||||
context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
await expectFeedbackPending(page);
|
||||
});
|
||||
await test.step("Save an edit and another comment before acknowledgement", async () => {
|
||||
await editSavedReview(page, "Sent version");
|
||||
await saveReviewComment(page, "Edited during send");
|
||||
await addSavedReview(page, "Added during send");
|
||||
await expectFeedbackPending(page);
|
||||
expect(gate.requestCount()).toBe(1);
|
||||
await captureFeedback(page, "pending");
|
||||
});
|
||||
await test.step("A real acknowledgement preserves both new versions", async () => {
|
||||
gate.accept();
|
||||
await expectFeedbackSent(page, "Composer diff stat");
|
||||
await expectSavedReview(page, "Edited during send");
|
||||
await expectSavedReview(page, "Added during send");
|
||||
await expectReviewAbsent(page, "Sent version");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function showCompactLayout(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
async function showWideLayout(page: Page) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
}
|
||||
async function selectAgentTab(page: Page, agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } });
|
||||
}
|
||||
async function selectDiffTab(page: Page) {
|
||||
await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } });
|
||||
}
|
||||
async function fillReviewComment(page: Page, body: string) {
|
||||
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
|
||||
}
|
||||
async function expectReviewAbsent(page: Page, body: string) {
|
||||
await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
async function reloadReview(page: Page) {
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
async function expectInlineCommentEditor(page: Page) {
|
||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0);
|
||||
}
|
||||
async function expectCompactCommentSheet(page: Page) {
|
||||
await expect(page.getByTestId("review-comment-sheet")).toBeVisible();
|
||||
await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 });
|
||||
for (const action of ["Save", "Cancel"])
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${action} review comment`, exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Save review comment", exact: true }),
|
||||
).toBeDisabled();
|
||||
}
|
||||
function feedbackButton(page: Page) {
|
||||
return page.getByRole("button", { name: "Send feedback (1)", exact: true });
|
||||
}
|
||||
async function openFeedbackWithKeyboard(page: Page) {
|
||||
await feedbackButton(page).focus();
|
||||
await expectFeedbackFocused(page);
|
||||
await reopenFeedbackWithKeyboard(page);
|
||||
}
|
||||
async function reopenFeedbackWithKeyboard(page: Page) {
|
||||
await page.keyboard.press("Enter");
|
||||
await expectRecipientMenu(page);
|
||||
const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true });
|
||||
await recipient.focus();
|
||||
await expect(recipient).toBeFocused();
|
||||
}
|
||||
async function expectFeedbackFocused(page: Page) {
|
||||
await expect(feedbackButton(page)).toBeFocused();
|
||||
}
|
||||
|
||||
async function captureWideFeedbackAppearance(page: Page) {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await expectSavedReview(page, "Review for second agent");
|
||||
await captureFeedback(page, `cta-wide-${colorScheme}-closed`);
|
||||
await sendFeedback(page, 1);
|
||||
await expectRecipientMenu(page);
|
||||
await captureFeedback(page, `cta-wide-${colorScheme}-open`);
|
||||
await dismissRecipientMenu(page);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
}
|
||||
|
||||
async function captureFeedback(page: Page, name: string) {
|
||||
await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` });
|
||||
}
|
||||
|
||||
async function closeAgentTab(page: Page, agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click();
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
|
||||
}
|
||||
async function expectFeedbackUnavailable(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function createReviewRecipient(
|
||||
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||
title: string,
|
||||
) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
}
|
||||
async function expectRecipientMenu(page: Page) {
|
||||
for (const name of ["Composer diff stat", "Second reviewer"]) {
|
||||
await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(2);
|
||||
}
|
||||
async function dismissRecipientMenu(page: Page) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem")).toHaveCount(0);
|
||||
}
|
||||
async function chooseFeedbackRecipient(page: Page, name: string) {
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
}
|
||||
async function expectFeedbackFailure(page: Page) {
|
||||
await expect(
|
||||
page.getByText("Requested mock prompt rejection", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled();
|
||||
}
|
||||
async function expectFeedbackPending(page: Page, count = 1) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }),
|
||||
).toBeDisabled();
|
||||
}
|
||||
|
||||
async function seedMultiFileReview() {
|
||||
const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [
|
||||
{ path: "z-last.ts", content: "export const final = 0;\n" },
|
||||
]);
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "z-last.ts"),
|
||||
Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") +
|
||||
"\n",
|
||||
);
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
return workspace;
|
||||
}
|
||||
async function expectFeedbackReachable(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport(
|
||||
{ ratio: 1 },
|
||||
);
|
||||
}
|
||||
async function expectReviewNavigationReachable(page: Page) {
|
||||
await expectFeedbackReachable(page);
|
||||
const jump = page.getByRole("button", { name: "Jump to file", exact: true });
|
||||
await expect(jump).toBeInViewport({ ratio: 1 });
|
||||
const navigationBounds = await jump.boundingBox();
|
||||
const feedbackBounds = await page
|
||||
.getByRole("button", { name: "Send feedback (1)", exact: true })
|
||||
.boundingBox();
|
||||
if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds");
|
||||
expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y);
|
||||
}
|
||||
async function jumpToReviewFile(page: Page, name: string) {
|
||||
await page.getByRole("button", { name: "Jump to file", exact: true }).click();
|
||||
const sheet = page.getByTestId("changes-jump-to-file-sheet");
|
||||
await page
|
||||
.getByTestId("changes-file-tree")
|
||||
.filter({ visible: true })
|
||||
.getByText(name, { exact: true })
|
||||
.click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport();
|
||||
}
|
||||
async function navigateWideReviewFile(page: Page, name: string) {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click();
|
||||
await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport();
|
||||
}
|
||||
|
||||
async function setReadmeDiff(
|
||||
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||
changed: boolean,
|
||||
) {
|
||||
await writeFile(
|
||||
path.join(workspace.cwd, "README.md"),
|
||||
changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n",
|
||||
);
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
}
|
||||
async function expectUnmatchedFeedback(page: Page) {
|
||||
await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText("Saved comments no longer match this diff.", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
function compactReviewSurface(page: Page) {
|
||||
return page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
}
|
||||
async function openCompactReviewExplorer(
|
||||
page: Page,
|
||||
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||
) {
|
||||
await showWideLayout(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await selectAgentTab(page, workspace.agentId);
|
||||
await showCompactLayout(page);
|
||||
await composerChangesPill(page).click();
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
|
||||
function reviewFileHeader(surface: Locator, name: string) {
|
||||
return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/);
|
||||
}
|
||||
|
||||
async function scrollReviewToLastLine(page: Page) {
|
||||
const surface = compactReviewSurface(page);
|
||||
const bounds = await surface.boundingBox();
|
||||
if (!bounds) throw new Error("Compact review has no bounds");
|
||||
await page.mouse.move(bounds.x + 120, bounds.y + 200);
|
||||
await page.mouse.wheel(0, 10000);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await surface.getByTestId("diff-file-1-body").boundingBox();
|
||||
const feedback = await page
|
||||
.getByRole("button", { name: "Send feedback (1)", exact: true })
|
||||
.boundingBox();
|
||||
if (!body || !feedback) return false;
|
||||
return body.y + body.height <= feedback.y;
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function revealCompactChanges(page: Page) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
async function expectReviewEditorBody(page: Page, body: string) {
|
||||
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
selectComposerDraftRange,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// The popover has no semantic role; its rows are React Native Pressables.
|
||||
function fileSuggestions(page: Page) {
|
||||
return page.getByTestId("composer-autocomplete-popover");
|
||||
}
|
||||
|
||||
async function selectReadmeMention(page: Page): Promise<void> {
|
||||
await fillComposerDraft(page, "@README");
|
||||
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
|
||||
await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, '"README.md"');
|
||||
}
|
||||
|
||||
async function sendEmailIntact(page: Page, email: string): Promise<void> {
|
||||
await fillComposerDraft(page, email);
|
||||
await expect(fileSuggestions(page)).not.toBeVisible();
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, "");
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-email-",
|
||||
title: "Email mention regression",
|
||||
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await test.step("complete a workspace file mention with Enter", async () => {
|
||||
await selectReadmeMention(page);
|
||||
});
|
||||
await test.step("send an email without offering or inserting workspace files", async () => {
|
||||
await sendEmailIntact(page, email);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-mention-",
|
||||
title: "Fullscreen mention keys",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const draft = "First line\nSecond line\n@README";
|
||||
await fillComposerDraft(page, draft);
|
||||
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(fileSuggestions(page)).not.toBeVisible();
|
||||
await selectComposerDraftRange(page, draft.length, draft.length);
|
||||
await composerLocator(page).press("ArrowUp");
|
||||
await expect
|
||||
.poll(() =>
|
||||
composerLocator(page).evaluate((element) => {
|
||||
const input = element as HTMLTextAreaElement;
|
||||
return input.selectionStart === input.selectionEnd && input.selectionStart < 23;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await selectComposerDraftRange(page, draft.length, draft.length);
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, `${draft}\n`);
|
||||
await exitComposerFullscreen(page);
|
||||
await selectReadmeMention(page);
|
||||
const commandDraft = "/cl\nSecond line\nThird line";
|
||||
await fillComposerDraft(page, commandDraft);
|
||||
await selectComposerDraftRange(page, 2, 2);
|
||||
await composerLocator(page).press("ArrowRight");
|
||||
await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible();
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(fileSuggestions(page)).not.toBeVisible();
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, "/cl\n\nSecond line\nThird line");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expectNearBottom,
|
||||
@@ -5,7 +7,27 @@ import {
|
||||
waitForScrollableChat,
|
||||
} from "../support/helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
attachComposerTestImage,
|
||||
pasteComposerTestImage,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
selectComposerDraftRange,
|
||||
expectComposerDraftSelection,
|
||||
expectComposerFullscreenControls,
|
||||
sendComposerWithButton,
|
||||
queueComposerWithButton,
|
||||
configureComposerQueueSend,
|
||||
activateRetainedComposerTab,
|
||||
recordVisibleComposerGeometry,
|
||||
expectNoCollapsedVisibleComposer,
|
||||
expectAttachmentPill,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
@@ -133,3 +155,300 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function writeCompactDraft(page: Page, text: string): Promise<void> {
|
||||
await composerLocator(page).fill(text);
|
||||
await expect(composerLocator(page)).toHaveValue(text);
|
||||
}
|
||||
|
||||
test("compact long drafts stop growing before the header", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-cap-",
|
||||
title: "Composer cap",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "A long draft line\n".repeat(40));
|
||||
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-",
|
||||
title: "Fullscreen composer",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await attachComposerTestImage(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await writeCompactDraft(page, "First line\nSecond line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await writeCompactDraft(
|
||||
page,
|
||||
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
|
||||
4,
|
||||
),
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
|
||||
await selectComposerDraftRange(page, 3, 12);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(composerLocator(page)).toHaveValue(
|
||||
"Edited fullscreen draft\nSecond line\nThird line",
|
||||
);
|
||||
await expectComposerDraftSelection(page, 3, 12);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await selectComposerDraftRange(page, 0, 5);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 0, 5);
|
||||
await expect(composerLocator(page)).toHaveValue("Short draft");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen submit rejection retains the editable draft and supports retry", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-retry-",
|
||||
title: "Fullscreen retry",
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
});
|
||||
const draft = "First line\nSecond line\nThird line";
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await sendComposerWithButton(page);
|
||||
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
|
||||
"Requested mock prompt rejection",
|
||||
);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await configureComposerQueueSend(page);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "composer-fullscreen-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for fullscreen queue test.",
|
||||
});
|
||||
try {
|
||||
await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await queueComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectQueuedMessageButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-paste-remount-",
|
||||
title: "Composer paste",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
|
||||
await enterComposerFullscreen(page);
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "First retained chat",
|
||||
});
|
||||
const second = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Second retained chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const draft = "Live first line\nLive second line\nLive third line";
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await activateRetainedComposerTab(page, first.title);
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Other tab draft");
|
||||
await shrinkVisibleViewport(page);
|
||||
await activateRetainedComposerTab(page, second.title);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
|
||||
try {
|
||||
const first = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "First capacity chat",
|
||||
});
|
||||
const second = await createMockIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Second capacity chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const draft = "Retained long line\n".repeat(40);
|
||||
await writeCompactDraft(page, draft);
|
||||
await recordVisibleComposerGeometry(page);
|
||||
await activateRetainedComposerTab(page, first.title);
|
||||
await writeCompactDraft(page, "Other visible draft");
|
||||
await activateRetainedComposerTab(page, second.title);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
|
||||
await expectNoCollapsedVisibleComposer(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function shrinkVisibleViewport(page: Page): Promise<void> {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
|
||||
await cdp.detach();
|
||||
}
|
||||
|
||||
async function panVisibleViewport(page: Page): Promise<void> {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: 4, y: 300 }],
|
||||
});
|
||||
for (let y = 290; y >= 100; y -= 10) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: 4, y }],
|
||||
});
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
|
||||
await cdp.detach();
|
||||
}
|
||||
|
||||
async function expectFullscreenInsideVisibleViewport(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("composer-fullscreen").evaluate((element) => {
|
||||
const viewport = window.visualViewport!;
|
||||
const frame = element.getBoundingClientRect();
|
||||
const send = element.querySelector('[aria-label="Send message"]')!.getBoundingClientRect();
|
||||
return {
|
||||
frameFits:
|
||||
Math.abs(frame.top - viewport.offsetTop) < 1 &&
|
||||
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
|
||||
Math.abs(frame.height - viewport.height) < 1 &&
|
||||
Math.abs(frame.width - viewport.width) < 1,
|
||||
sendFits:
|
||||
send.top >= viewport.offsetTop &&
|
||||
send.bottom <= viewport.offsetTop + viewport.height &&
|
||||
send.left >= viewport.offsetLeft &&
|
||||
send.right <= viewport.offsetLeft + viewport.width,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.toEqual({ frameFits: true, sendFits: true });
|
||||
}
|
||||
|
||||
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-visible-viewport-",
|
||||
title: "Visible viewport composer",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await shrinkVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await panVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
||||
const image = page.getByRole("img", { name, exact: true });
|
||||
await expect(image).toBeVisible();
|
||||
await expect
|
||||
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function watchRequestsTo(page: Page, origin: string): string[] {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
@@ -96,6 +105,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
title: "Non-ASCII file link",
|
||||
initialPrompt: "Show the report link",
|
||||
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const order of ["raw-first", "href-first"] as const) {
|
||||
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "percent-link-cache-",
|
||||
title: "Distinct file links",
|
||||
initialPrompt: "Show both filenames",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
|
||||
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
|
||||
await openAgentRoute(page, session);
|
||||
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
|
||||
for (const link of links) {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
if (link === "raw") {
|
||||
await openAssistantInlinePath(page, "literal%20.md");
|
||||
await expectFileTabOpen(page, "literal%20.md");
|
||||
await expectPreviewText(page, "Literal filename");
|
||||
} else {
|
||||
await openAssistantMarkdownLink(page, "Decoded");
|
||||
await expectFileTabOpen(page, "literal .md");
|
||||
await expectPreviewText(page, "Decoded filename");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
@@ -626,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -757,3 +858,24 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("link", { name, exact: true }).first().click();
|
||||
}
|
||||
|
||||
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
|
||||
}
|
||||
|
||||
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
|
||||
await page.getByText(token, { exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPreviewText(page: Page, text: string): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText(text, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
selectWorkspaceIsolation,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
enterComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
expectComposerFullscreenControls,
|
||||
} from "../support/helpers/composer";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
@@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function startDictationWithShortcut(page: Page): Promise<void> {
|
||||
await composerLocator(page).press("Control+d");
|
||||
}
|
||||
|
||||
test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "fullscreen-dictation-",
|
||||
title: "Fullscreen dictation shortcut",
|
||||
});
|
||||
await installSyntheticMicrophone(page);
|
||||
const harness = await installDictationFailureHarness(page);
|
||||
const draft = "First line\nSecond line\nThird line";
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await fillComposerDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await startDictationWithShortcut(page);
|
||||
await harness.waitForAudio();
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Insert transcription", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Insert transcription and send", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await enterComposerFullscreen(page);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await composerLocator(page).press("Control+Shift+d");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("Realtime voice is disabled in this test.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,13 +9,12 @@ import {
|
||||
expectTerminalPreviewCommand,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
launchTrigger,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
terminalLaunchSubmit,
|
||||
terminalPromptInput,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// Someone who wants a terminal agent instead of a chat: they pick a profile
|
||||
@@ -42,17 +41,18 @@ const BARE_PROFILE: TerminalProfile = {
|
||||
args: ["-c", "echo bare-launch-static-line; exec cat"],
|
||||
};
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } },
|
||||
});
|
||||
|
||||
test.describe("New workspace: launching a terminal", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
|
||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
@@ -97,4 +97,51 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
||||
});
|
||||
});
|
||||
|
||||
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
|
||||
const selectProfile = async (profile: TerminalProfile) => {
|
||||
await launchTrigger(page).click();
|
||||
const option = page.getByRole("menuitem").filter({ hasText: profile.name });
|
||||
await option.click();
|
||||
await expect(option).not.toBeVisible();
|
||||
};
|
||||
const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
|
||||
await test.step("restore a multiline prompt after a read-only profile", async () => {
|
||||
await selectProfile(PROMPT_PROFILE);
|
||||
await fillTerminalPrompt(page, prompt);
|
||||
await expect(fullscreen).toBeVisible();
|
||||
await selectProfile(BARE_PROFILE);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await selectProfile(PROMPT_PROFILE);
|
||||
await expect(terminalPromptInput(page)).toHaveValue(prompt);
|
||||
await expect(fullscreen).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
|
||||
await fillTerminalPrompt(page, "Short draft");
|
||||
await expect(fullscreen).toHaveCount(0);
|
||||
await selectProfile(BARE_PROFILE);
|
||||
await selectProfile(PROMPT_PROFILE);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("Short draft");
|
||||
await expect(fullscreen).toHaveCount(0);
|
||||
await terminalPromptInput(page).press("End");
|
||||
await terminalPromptInput(page).press("Shift+Enter");
|
||||
await terminalPromptInput(page).pressSequentially("Second");
|
||||
await terminalPromptInput(page).press("Shift+Enter");
|
||||
await terminalPromptInput(page).pressSequentially("Third");
|
||||
await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
|
||||
await expect(fullscreen).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
expectPairingOffer,
|
||||
expectCompletePairingQr,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
preparePairingHost,
|
||||
@@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
|
||||
mutableRelay: { enabled: true },
|
||||
});
|
||||
try {
|
||||
await preparePairingHost(page, daemon);
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
|
||||
await expectCompletePairingQr(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const test = base.extend<{ projectWithTrailingSpace: string }>({
|
||||
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
|
||||
try {
|
||||
const repoPath = path.join(root, "repo ");
|
||||
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@getpaseo.local",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: repoPath, stdio: "pipe" },
|
||||
);
|
||||
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
|
||||
const added = await client.addProject(repoPath);
|
||||
expect(added.error).toBeNull();
|
||||
const projectId = added.project!.projectId;
|
||||
try {
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath, projectId },
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
await provide(created.workspace!.id);
|
||||
} finally {
|
||||
await client.removeProject(projectId);
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
test.skip(
|
||||
process.platform === "win32",
|
||||
"Windows does not preserve trailing spaces in folder names",
|
||||
);
|
||||
|
||||
test("Changes shows files in a project whose folder ends in a space", async ({
|
||||
page,
|
||||
projectWithTrailingSpace,
|
||||
}, testInfo) => {
|
||||
await gotoWorkspace(page, projectWithTrailingSpace);
|
||||
await openChangesTreePanel(page);
|
||||
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("changes.png") });
|
||||
});
|
||||
@@ -10,16 +10,29 @@ import {
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
|
||||
const CUSTOM_PROVIDER = {
|
||||
id: "junie",
|
||||
name: "Junie",
|
||||
} as const;
|
||||
|
||||
const PLUGIN_PROVIDER = {
|
||||
id: "direct-example",
|
||||
name: "Direct provider example",
|
||||
pluginId: "provider-direct-example",
|
||||
} as const;
|
||||
|
||||
interface ProviderRemovalDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
|
||||
patchDaemonConfig(config: {
|
||||
removeProviders?: string[];
|
||||
pluginsEnabled?: boolean;
|
||||
}): Promise<unknown>;
|
||||
installDirectoryPlugin(path: string): Promise<unknown>;
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
getProvidersSnapshot(): Promise<{
|
||||
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
|
||||
}>;
|
||||
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
const plugin = await copyPluginExample("provider-direct");
|
||||
try {
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
const pluginProviderRow = page.getByRole("button", {
|
||||
name: `${PLUGIN_PROVIDER.name} provider details`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
|
||||
|
||||
// The provider survives a removeProviders patch pointed at it: the
|
||||
// plugin, not config.json, owns the definition.
|
||||
await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await plugin.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionPreview(page, {
|
||||
option: "Behind feature flag",
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(12);
|
||||
return rows.evaluateAll(
|
||||
(elements, prefix) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
|
||||
`sidebar-workspace-row-${getServerId()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
async function navigateWorkspace(
|
||||
page: Page,
|
||||
direction: "next" | "previous",
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
|
||||
}
|
||||
|
||||
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "relative-navigation-",
|
||||
title: "Workspace 01",
|
||||
});
|
||||
try {
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
title: `Workspace ${String(index).padStart(2, "0")}`,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await navigateWorkspace(page, "next", order[9]);
|
||||
await navigateWorkspace(page, "previous", order[8]);
|
||||
});
|
||||
|
||||
await test.step("wrapping uses the true first and last visible workspace", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await navigateWorkspace(page, "previous", order[11]);
|
||||
await navigateWorkspace(page, "next", order[0]);
|
||||
});
|
||||
|
||||
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
|
||||
|
||||
async function openTabSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
const footer = page.getByRole("button", { name: "New terminal", exact: true });
|
||||
await expect(footer).toBeVisible();
|
||||
// Visibility alone passes while the sheet is still travelling up from below the viewport.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await footer.boundingBox();
|
||||
return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
|
||||
}
|
||||
|
||||
async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
|
||||
await page.screenshot({ path: testInfo.outputPath(name + ".png") });
|
||||
}
|
||||
|
||||
async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
|
||||
await openTabSwitcher(page);
|
||||
await page.getByRole("button", { name: kind, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
|
||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await captureScreen(page, testInfo, "compact-chat-header");
|
||||
|
||||
// Open the sessions dropdown (a bottom sheet on compact).
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
||||
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
|
||||
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
|
||||
await captureScreen(page, testInfo, "renamed-tab-in-switcher");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("switcher footer creates tabs and returns to the existing chat", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-header-actions-",
|
||||
title: "Existing chat",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
|
||||
|
||||
await createTabFromSwitcher(page, "New terminal");
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
|
||||
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
|
||||
await captureScreen(page, testInfo, "compact-terminal-header");
|
||||
|
||||
await createTabFromSwitcher(page, "New agent");
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await captureScreen(page, testInfo, "compact-draft-header");
|
||||
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
|
||||
await captureScreen(page, testInfo, "switcher-three-tabs");
|
||||
await page.getByText("Existing chat", { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
|
||||
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 10, y: 10 } });
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
await captureScreen(page, testInfo, "returned-chat-header");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Native question option preview
|
||||
|
||||
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"rollout\"" 20000
|
||||
press "label=\"rollout\""
|
||||
wait "id=\"question-form-option-preview\"" 10000
|
||||
gesture pan 570 1120 0 -440 600
|
||||
is visible "text=\"// LAST PREVIEW LINE\""
|
||||
close
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
|
||||
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-surface\"" 10000
|
||||
press "id=\"terminal-surface\""
|
||||
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
|
||||
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
|
||||
|
||||
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
run_host_scenario chat
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="workspace-header-menu-trigger"' --settle
|
||||
ad wait 'id="workspace-header-new-agent"' 10000
|
||||
ad press 'id="workspace-header-new-agent"' --settle
|
||||
ad press 'id="workspace-tab-switcher-trigger"' --settle
|
||||
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
|
||||
ad press 'id="workspace-tab-switcher-new-agent"' --settle
|
||||
ad wait 'text="New Agent"' 10000
|
||||
run_host_scenario workspace-draft
|
||||
ad keyboard dismiss || true
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Start on Settings > Host > Pair device with relay enabled in the Android app.
|
||||
// node packages/app/e2e/mobile/pair-device/android.mjs <agent-device-session>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session] = process.argv;
|
||||
assert(session, "Pass the agent-device session for the connected Android app");
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
ad(
|
||||
"press",
|
||||
'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
|
||||
"--settle",
|
||||
);
|
||||
ad("wait", "text", "Pairing QR code", "10000");
|
||||
const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
|
||||
const qr = data.nodes.find((node) => node.label === "Pairing QR code");
|
||||
assert(sheet?.rect, "Pairing sheet must be visible");
|
||||
assert(qr?.rect, "Pairing QR code must be visible");
|
||||
assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
|
||||
assert(
|
||||
qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
|
||||
"QR must have an unclipped right margin inside the sheet",
|
||||
);
|
||||
assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
|
||||
assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
|
||||
assert(
|
||||
qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
|
||||
"QR bottom must be visible inside the sheet",
|
||||
);
|
||||
assert(
|
||||
data.nodes.some((node) => node.label === "Copy" && node.enabled),
|
||||
"Copy must remain available",
|
||||
);
|
||||
console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
|
||||
ad("press", 'label="Close"', "--settle");
|
||||
@@ -0,0 +1,62 @@
|
||||
// Open Settings > Appearance in a native Android app loaded from this checkout.
|
||||
// AGENT_DEVICE_STATE_DIR=<state-dir> node packages/app/e2e/mobile/system-bars/android.mjs <session> <adb-serial> <app-id>
|
||||
// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, serial, appId] = process.argv;
|
||||
assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function windowState() {
|
||||
const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
|
||||
const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
|
||||
assert(regions, "Android must report the status-bar appearance region");
|
||||
return regions.includes("LIGHT_STATUS_BARS");
|
||||
}
|
||||
|
||||
async function expectDarkIcons(expected, context) {
|
||||
const deadline = Date.now() + 5_000;
|
||||
do {
|
||||
if (windowState() === expected) {
|
||||
console.log(`PASS: ${context}`);
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
} while (Date.now() < deadline);
|
||||
assert.equal(windowState(), expected, context);
|
||||
}
|
||||
|
||||
function selectTheme(theme) {
|
||||
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
|
||||
assert(current, "Start on Settings > Appearance");
|
||||
ad("press", `label="Theme: ${current}"`, "--settle");
|
||||
ad("press", `label="${theme}"`, "--settle");
|
||||
ad("wait", "text", `Theme: ${theme}`, "5000");
|
||||
}
|
||||
|
||||
ad("settings", "appearance", "light");
|
||||
selectTheme("Light");
|
||||
await expectDarkIcons(true, "Light app theme uses dark status icons");
|
||||
selectTheme("Dark");
|
||||
await expectDarkIcons(false, "Dark app theme uses light status icons");
|
||||
selectTheme("System");
|
||||
await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
|
||||
ad("settings", "appearance", "dark");
|
||||
await expectDarkIcons(
|
||||
false,
|
||||
"System theme follows an OS change to dark without reopening settings",
|
||||
);
|
||||
ad("settings", "appearance", "light");
|
||||
await expectDarkIcons(true, "System theme follows an OS change back to light");
|
||||
@@ -6,6 +6,8 @@ type WebSocketMessage = string | Buffer;
|
||||
interface SendAgentMessageRequest {
|
||||
type: "send_agent_message_request";
|
||||
requestId: string;
|
||||
text: unknown;
|
||||
attachments: unknown;
|
||||
agentId: string;
|
||||
/** Absent when the client sends into an idle agent. */
|
||||
activeTurnBehavior?: string;
|
||||
@@ -29,6 +31,8 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
|
||||
return {
|
||||
type: "send_agent_message_request",
|
||||
requestId: request.requestId,
|
||||
text: request.text,
|
||||
attachments: request.attachments,
|
||||
agentId: request.agentId,
|
||||
activeTurnBehavior:
|
||||
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
|
||||
@@ -74,6 +78,7 @@ export async function gateNextAgentMessage(page: Page) {
|
||||
|
||||
return {
|
||||
waitForRequest,
|
||||
requestCount: () => requests.length,
|
||||
accept(index = 0) {
|
||||
const heldMessage = heldMessages[index];
|
||||
if (!serverSocket || !heldMessage) {
|
||||
|
||||
@@ -316,3 +316,133 @@ export async function openGithubWorkspace(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const COMPOSER_TEST_IMAGE_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
export async function attachComposerTestImage(page: Page): Promise<void> {
|
||||
await attachImageFromMenu(page, {
|
||||
name: "fullscreen.png",
|
||||
mimeType: "image/png",
|
||||
buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function pasteComposerTestImage(page: Page): Promise<boolean> {
|
||||
return composerInput(page).evaluate((input, base64) => {
|
||||
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
|
||||
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}, COMPOSER_TEST_IMAGE_BASE64);
|
||||
}
|
||||
|
||||
export async function enterComposerFullscreen(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Fullscreen", exact: true }).click();
|
||||
}
|
||||
export async function exitComposerFullscreen(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click();
|
||||
}
|
||||
export async function resizeComposerViewport(
|
||||
page: Page,
|
||||
orientation: "portrait" | "landscape" | "short",
|
||||
): Promise<void> {
|
||||
const sizes = {
|
||||
portrait: { width: 390, height: 844 },
|
||||
landscape: { width: 844, height: 390 },
|
||||
short: { width: 390, height: 390 },
|
||||
};
|
||||
await page.setViewportSize(sizes[orientation]);
|
||||
}
|
||||
export async function selectComposerDraftRange(
|
||||
page: Page,
|
||||
start: number,
|
||||
end: number,
|
||||
): Promise<void> {
|
||||
await composerInput(page).focus();
|
||||
await composerInput(page).evaluate(
|
||||
(element, selection) => {
|
||||
const input = element as HTMLTextAreaElement;
|
||||
input.setSelectionRange(selection.start, selection.end);
|
||||
input.dispatchEvent(new Event("select", { bubbles: true }));
|
||||
},
|
||||
{ start, end },
|
||||
);
|
||||
}
|
||||
export async function expectComposerDraftSelection(
|
||||
page: Page,
|
||||
start: number,
|
||||
end: number,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
composerInput(page).evaluate((element) => {
|
||||
const input = element as HTMLTextAreaElement;
|
||||
return [input.selectionStart, input.selectionEnd];
|
||||
}),
|
||||
)
|
||||
.toEqual([start, end]);
|
||||
}
|
||||
export async function expectComposerFullscreenControls(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
|
||||
await expect(composerInput(page)).toHaveCount(1);
|
||||
}
|
||||
export async function sendComposerWithButton(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
}
|
||||
export async function queueComposerWithButton(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Queue message", exact: true }).click();
|
||||
}
|
||||
export async function configureComposerQueueSend(page: Page): Promise<void> {
|
||||
await page.addInitScript(() =>
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })),
|
||||
);
|
||||
}
|
||||
|
||||
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
|
||||
* This exercises retained activity changes, not a claim that covered chrome is clickable.
|
||||
*/
|
||||
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("workspace-tab-switcher-trigger")
|
||||
.evaluate((element) => (element as HTMLElement).click());
|
||||
const option = page.getByText(title, { exact: true }).filter({ visible: true }).last();
|
||||
await expect(option).toBeVisible();
|
||||
await option.evaluate((element) => (element as HTMLElement).click());
|
||||
}
|
||||
|
||||
export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const frames: { input: number; root: number }[] = [];
|
||||
Reflect.set(window, "__composerGeometryFrames", frames);
|
||||
let running = true;
|
||||
const tick = () => {
|
||||
if (!running) return;
|
||||
for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) {
|
||||
if (root.getClientRects().length === 0) continue;
|
||||
const input = root.querySelector("textarea");
|
||||
if (input)
|
||||
frames.push({
|
||||
input: input.getBoundingClientRect().height,
|
||||
root: root.getBoundingClientRect().height,
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
Reflect.set(window, "__stopComposerGeometry", () => {
|
||||
running = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
|
||||
const frames = await page.evaluate(() => {
|
||||
Reflect.get(window, "__stopComposerGeometry")?.();
|
||||
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
|
||||
});
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function openCustomModels(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
|
||||
await page
|
||||
.getByTestId("add-custom-model-sheet")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
@@ -220,3 +220,24 @@ export function expectDaemonPidUnchanged(
|
||||
expect(before).toBeDefined();
|
||||
expect(after).toBe(before);
|
||||
}
|
||||
|
||||
export async function expectCompletePairingQr(page: Page): Promise<void> {
|
||||
const qr = page.getByRole("img", { name: "Pairing QR code" });
|
||||
await qr.scrollIntoViewIfNeeded();
|
||||
// IntersectionObserver rounds fractional sheet/scroll coordinates.
|
||||
await expect(qr).toBeInViewport({ ratio: 0.999 });
|
||||
const bounds = await qr.evaluate((element) => {
|
||||
const tile = element.parentElement;
|
||||
if (!tile) throw new Error("Pairing QR tile is missing");
|
||||
const qrRect = element.getBoundingClientRect();
|
||||
const tileRect = tile.getBoundingClientRect();
|
||||
return {
|
||||
qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
|
||||
tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
|
||||
};
|
||||
});
|
||||
expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
|
||||
expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
|
||||
expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
|
||||
expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ async function capture(
|
||||
name: string,
|
||||
subject: Locator = headerButtons(page),
|
||||
) {
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = info.outputPath(`${name}.png`);
|
||||
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
|
||||
refreshFromHeaderAndComposer: () =>
|
||||
test.step("icon-only header action and labeled composer action update in place", async () => {
|
||||
await exampleCommand(page, "action");
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await headerButtons(page).click();
|
||||
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
|
||||
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
|
||||
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
|
||||
).toContainText("Refreshed · 2");
|
||||
await capture(page, info, "01-actions");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await capture(page, info, "02-compact-actions");
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"02-compact-actions",
|
||||
page.getByRole("button", { name: "Refresh context", exact: true }),
|
||||
);
|
||||
}),
|
||||
useMenusAndToggleButtons: () =>
|
||||
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
|
||||
@@ -128,7 +134,8 @@ export async function withButtonAuthorExample(
|
||||
await capture(page, info, "05-disabled");
|
||||
await exampleCommand(page, "enable");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await headerButtons(page).click();
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
).toBeInViewport();
|
||||
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
|
||||
inspectLiveWorkspaceDetails: () =>
|
||||
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
|
||||
await exampleCommand(page, "popover");
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
|
||||
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
|
||||
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
|
||||
}
|
||||
|
||||
async function holdButton(page: Page, button: Locator) {
|
||||
await button.hover();
|
||||
await page.mouse.down();
|
||||
}
|
||||
|
||||
async function openCompactOverflowWithHeaderChrome(page: Page) {
|
||||
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
|
||||
const action = page.getByRole("button", { name: "Deploy application", exact: true });
|
||||
const overflow = page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true });
|
||||
await expect(action).toHaveCSS("border-top-width", "0px");
|
||||
await expect(overflow).toHaveCSS("border-top-width", "0px");
|
||||
await hamburger.hover();
|
||||
const highlight = await hamburger.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await action.hover();
|
||||
await expect(action).toHaveCSS("background-color", highlight);
|
||||
await holdButton(page, overflow);
|
||||
await expect(overflow).toHaveCSS("background-color", highlight);
|
||||
await expectSurfaceVisible(page);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function withButtonShowcase(
|
||||
page: Page,
|
||||
run: (buttons: {
|
||||
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
|
||||
);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
|
||||
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
|
||||
"Button showcase with a long workspace title",
|
||||
);
|
||||
await expectHeaderTextTruncated(page);
|
||||
await expectSurfaceVisible(page);
|
||||
await openCompactOverflowWithHeaderChrome(page);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Header checks");
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await showcase.setTitle("Compact checks");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }),
|
||||
).toBeInViewport();
|
||||
await showcase.setTitle("Button showcase");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
|
||||
@@ -47,6 +47,65 @@ export default function contribute(client) {
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
const PROMPT_PLUGIN_ID = "timeline-prompt-e2e";
|
||||
|
||||
function promptClientSource(prompts?: readonly string[]): string {
|
||||
return `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { z } from "zod";
|
||||
|
||||
const selectedPrompts = ${JSON.stringify(prompts ?? null)};
|
||||
|
||||
function PromptCard({ item }) {
|
||||
return <View testID="plugin-prompt"><Text>{item.data.text}</Text></View>;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addTimelineTransformer({
|
||||
id: "prompt",
|
||||
query: { itemType: "user_message" },
|
||||
transform({ item }) {
|
||||
if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined;
|
||||
return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] };
|
||||
},
|
||||
});
|
||||
client.addTimelineRenderer({
|
||||
kind: "prompt", version: 1,
|
||||
schema: z.object({ text: z.string() }),
|
||||
Component: PromptCard,
|
||||
});
|
||||
return () => {};
|
||||
}`;
|
||||
}
|
||||
|
||||
async function withDirectoryPlugin(options: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
run: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const { pluginId, source, run } = options;
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
|
||||
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await pluginClient.getDaemonConfig();
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: pluginId, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), source);
|
||||
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await pluginClient.installDirectoryPlugin(directory);
|
||||
await run();
|
||||
} finally {
|
||||
await pluginClient.removePlugin(pluginId);
|
||||
await pluginClient.patchDaemonConfig({
|
||||
pluginsEnabled: previous.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await pluginClient.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTimelinePlugin(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
@@ -60,7 +119,6 @@ export async function withTimelinePlugin(
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-plugin-",
|
||||
title: "Timeline plugin regression",
|
||||
@@ -73,40 +131,53 @@ export async function withTimelinePlugin(
|
||||
}
|
||||
: {},
|
||||
});
|
||||
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await pluginClient.getDaemonConfig();
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
|
||||
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await pluginClient.installDirectoryPlugin(directory);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await run(agent);
|
||||
await info.attach("timeline-plugin", {
|
||||
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
|
||||
contentType: "image/png",
|
||||
await withDirectoryPlugin({
|
||||
pluginId: PLUGIN_ID,
|
||||
source: CLIENT_SOURCE,
|
||||
run: async () => {
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent..." }).first(),
|
||||
).toBeVisible();
|
||||
await run(agent);
|
||||
await info.attach("timeline-plugin", {
|
||||
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} catch (error) {
|
||||
await info.attach("timeline-before-cleanup", {
|
||||
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await info.attach("timeline-before-cleanup", {
|
||||
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await pluginClient.removePlugin(PLUGIN_ID);
|
||||
await pluginClient.patchDaemonConfig({
|
||||
pluginsEnabled: previous.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await pluginClient.close();
|
||||
await agent.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws all prompts, or only the selected prompts, as plugin cards. */
|
||||
export async function withPromptTimelinePlugin(
|
||||
run: () => Promise<void>,
|
||||
prompts?: readonly string[],
|
||||
): Promise<void> {
|
||||
await withDirectoryPlugin({
|
||||
pluginId: PROMPT_PLUGIN_ID,
|
||||
source: promptClientSource(prompts),
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectPromptsDrawnByPlugin(page: Page): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(timeline.getByTestId("user-message")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
|
||||
}
|
||||
@@ -142,3 +213,21 @@ export async function expectBothConsecutiveTools(page: Page): Promise<void> {
|
||||
await grep.scrollIntoViewIfNeeded();
|
||||
await expect(grep).toBeVisible();
|
||||
}
|
||||
|
||||
/** The row belongs to the virtualized history, not the always-mounted recent window. */
|
||||
export async function expectVirtualizedPluginPromptVisible(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(
|
||||
timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise<void> {
|
||||
await info.attach("plugin-outline-jump", {
|
||||
body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionPreview(
|
||||
page: Page,
|
||||
input: { option: string; preview: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
questionOption(page, input.option).getByTestId("question-form-option-preview"),
|
||||
).toContainText(input.preview);
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
|
||||
@@ -49,31 +49,37 @@ export async function seedParentWithSubagent(
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.childTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
},
|
||||
});
|
||||
const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
title: input.parentTitle,
|
||||
},
|
||||
child: {
|
||||
id: child.id,
|
||||
title: input.childTitle,
|
||||
},
|
||||
child,
|
||||
workspaceId: workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */
|
||||
export async function seedSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string; title: string }> {
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: input.parentId,
|
||||
},
|
||||
});
|
||||
return { id: child.id, title: input.title };
|
||||
}
|
||||
|
||||
export async function seedParentWithCrossWorkspaceSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** On compact the track opens as a sheet, which closes once a row is chosen. */
|
||||
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The compact workspace sheet, named after the tab it hosts. */
|
||||
export function workspaceSheet(page: Page, label: string): Locator {
|
||||
return page.getByRole("dialog", { name: label });
|
||||
}
|
||||
|
||||
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
|
||||
const SHEET_SNAP_FRACTION = 0.92;
|
||||
/** The dialog starts below the sheet's drag handle. */
|
||||
const SHEET_HANDLE_MAX_HEIGHT = 32;
|
||||
|
||||
/**
|
||||
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
|
||||
* covers the workspace header; a sheet sunk below it hides content.
|
||||
*/
|
||||
export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise<void> {
|
||||
const viewportHeight = page.viewportSize()?.height ?? 0;
|
||||
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await sheet.boundingBox();
|
||||
if (!box) return null;
|
||||
const top = Math.round(box.y);
|
||||
return {
|
||||
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
|
||||
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
|
||||
};
|
||||
})
|
||||
.toEqual({ dockedToBottom: true, topAtSnap: true });
|
||||
}
|
||||
|
||||
export async function expectOpenTabCount(page: Page, count: number): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await sheet.getByRole("button", { name: "Close" }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise<void> {
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await sheet.getByRole("button", { name: "Open as tab" }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function replaceWorkspaceSheetFileText(
|
||||
page: Page,
|
||||
label: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const editor = workspaceSheet(page, label)
|
||||
.getByTestId("file-source-editor")
|
||||
.locator(".cm-content");
|
||||
await editor.click();
|
||||
await editor.press("ControlOrMeta+A");
|
||||
await editor.pressSequentially(content);
|
||||
}
|
||||
|
||||
export async function selectTabFromSwitcher(page: Page, label: string): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
/** Runs a close that hits the unsaved-changes guard, and declines the discard. */
|
||||
export async function expectDeclinedDiscardPrompt(
|
||||
page: Page,
|
||||
close: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const prompt = page.waitForEvent("dialog").then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
return message;
|
||||
});
|
||||
await close();
|
||||
expect(await prompt).toContain("discard the draft");
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
// Polyfill crypto.randomUUID for React Native before any other imports
|
||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||
polyfillCrypto();
|
||||
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||
polyfillScreenOrientation();
|
||||
// Install runtime polyfills before any other module evaluates. Keep this the first import.
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
|
||||
import "./src/polyfills/install-early";
|
||||
|
||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||
import "./src/styles/unistyles";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const { withAndroidManifest } = require("expo/config-plugins");
|
||||
|
||||
// Requesting CAMERA implies these features are required, which hides the app
|
||||
// on Google Play for devices without a camera. Pairing works without QR scanning.
|
||||
const OPTIONAL_CAMERA_FEATURES = ["android.hardware.camera", "android.hardware.camera.autofocus"];
|
||||
|
||||
function withAndroidOptionalCamera(config) {
|
||||
return withAndroidManifest(config, (modConfig) => {
|
||||
const manifest = modConfig.modResults.manifest;
|
||||
const features = manifest["uses-feature"] ?? [];
|
||||
for (const name of OPTIONAL_CAMERA_FEATURES) {
|
||||
const existing = features.find((feature) => feature.$["android:name"] === name);
|
||||
if (existing) {
|
||||
existing.$["android:required"] = "false";
|
||||
} else {
|
||||
features.push({ $: { "android:name": name, "android:required": "false" } });
|
||||
}
|
||||
}
|
||||
manifest["uses-feature"] = features;
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidOptionalCamera;
|
||||
@@ -29,4 +29,15 @@ describe("formatThinkingOptionLabel", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
|
||||
});
|
||||
|
||||
it("preserves provider-supplied xhigh labels with extra detail", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (56 | 8m)" })).toBe(
|
||||
"X high (56 | 8m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (45 | 6m)" })).toBe(
|
||||
"X high (45 | 6m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh" })).toBe("Extra high");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "Extra high" })).toBe("Extra high");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,9 @@ export function formatAgentModeLabel(mode: ControlLabelInput): string {
|
||||
|
||||
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
|
||||
const rawLabel = (option.label ?? option.id).trim();
|
||||
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
|
||||
if (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
if (compactLabel === "xhigh" || compactLabel === "extrahigh") {
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
|
||||
const runtime = {
|
||||
@@ -29,7 +29,7 @@ const runtime = {
|
||||
} as unknown as PluginClientRuntime;
|
||||
|
||||
function installProbe(
|
||||
itemType: "assistant_message" | "tool_call" | "reasoning",
|
||||
itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
|
||||
condition = "true",
|
||||
): InstalledPlugin {
|
||||
const clientBundle = `(function() {
|
||||
@@ -160,6 +160,23 @@ describe("stream presentation through installed plugins", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("addresses a prompt a plugin replaced by the prompt's own id", () => {
|
||||
const harness = streamHarness(installedTransform(installProbe("user_message")));
|
||||
const rendered = rows(
|
||||
harness.send({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" },
|
||||
}),
|
||||
);
|
||||
const source = rows(harness.source());
|
||||
|
||||
expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
|
||||
expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
|
||||
// The chat outline jumps to the source id; the row it lands on is the plugin's.
|
||||
expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
|
||||
});
|
||||
|
||||
it("finishes one message before streaming a different message ID", () => {
|
||||
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
|
||||
harness.send(assistant("First\n\nBody", "first"));
|
||||
|
||||
@@ -26,10 +26,14 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
|
||||
/**
|
||||
* The source message a display row belongs to. Every assistant message is split into
|
||||
* Markdown blocks, so an assistant row id never equals its message id; anything that
|
||||
* addresses a message — find, scroll-to-message, history reveal — asks for this.
|
||||
* addresses a message — find, scroll-to-message, history reveal — asks for this. A
|
||||
* plugin row a timeline transformer projected answers for the item it replaced, whose
|
||||
* id is what the chat outline and find hold.
|
||||
*/
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
|
||||
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useStreamTopOverlayInset } from "./top-overlay";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
@@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
const topOverlayInset = useStreamTopOverlayInset();
|
||||
// The list is inverted, so its content's bottom padding is the visual top.
|
||||
const listContentContainerStyle = useMemo(
|
||||
() => [
|
||||
baseListContentContainerStyle,
|
||||
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
|
||||
{
|
||||
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
|
||||
},
|
||||
],
|
||||
[
|
||||
baseListContentContainerStyle,
|
||||
streamKeyboardInset.contentContainerPaddingBottom,
|
||||
topOverlayInset,
|
||||
],
|
||||
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
|
||||
);
|
||||
const listInsetProps = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
|
||||
export const supportsStreamTopOverlay = isNative;
|
||||
|
||||
const StreamTopOverlayContext = createContext(0);
|
||||
|
||||
/** Visual-top clearance supplied by the containing layout, in points. */
|
||||
export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
|
||||
|
||||
export function useStreamTopOverlayInset(): number {
|
||||
return useContext(StreamTopOverlayContext);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
@@ -35,7 +36,7 @@ import { ChangelogHost } from "@/changelog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
|
||||
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
@@ -102,7 +103,6 @@ import {
|
||||
useKeyboardActionDispatcher,
|
||||
} from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { polyfillNavigator } from "@/polyfills/navigator";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
@@ -116,6 +116,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -138,7 +139,6 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
|
||||
polyfillNavigator();
|
||||
polyfillCrypto();
|
||||
|
||||
export interface HostRuntimeBootstrapState {
|
||||
@@ -550,6 +550,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
<WorkspaceLayoutModePublisher />
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
@@ -625,7 +626,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
surface
|
||||
);
|
||||
|
||||
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
||||
return (
|
||||
<ComposerEditingSessionsProvider>
|
||||
<CommandCenterProvider>{content}</CommandCenterProvider>
|
||||
</ComposerEditingSessionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@/plugins/themes";
|
||||
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||
import { applyAppearance } from "./apply";
|
||||
import { AppearanceSystemBars } from "./system-bars";
|
||||
|
||||
interface ContributedThemes {
|
||||
options: PluginThemeOption[];
|
||||
@@ -107,7 +108,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
if (!hasAppliedAppearance) return null;
|
||||
|
||||
return (
|
||||
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
|
||||
<ContributedThemesContext.Provider value={value}>
|
||||
<AppearanceSystemBars />
|
||||
{children}
|
||||
</ContributedThemesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
function ThemeSystemBars({ colorScheme }: Pick<Theme, "colorScheme">) {
|
||||
return <SystemBars style={colorScheme === "light" ? "dark" : "light"} />;
|
||||
}
|
||||
|
||||
export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
export function AppearanceSystemBars() {
|
||||
return null;
|
||||
}
|
||||
@@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => {
|
||||
}
|
||||
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
|
||||
});
|
||||
|
||||
it("decodes percent-encoded relative markdown hrefs", () => {
|
||||
// markdown-it percent-encodes non-ASCII characters in hrefs, so a link
|
||||
// written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
|
||||
expect(
|
||||
parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
|
||||
path: "/Users/test/project/docs/reports/开户赠金.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded spaces in relative markdown hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("docs/my%20file.md#L5", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/my%20file.md#L5",
|
||||
path: "/Users/test/project/docs/my file.md",
|
||||
lineStart: 5,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded absolute hrefs with line suffixes", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: 3,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded home-relative hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "~/notes/%E5%BC%80%E6%88%B7.md",
|
||||
path: "~/notes/开户.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded Windows hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
|
||||
workspaceRoot: "C:/repo",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/my%20dir/app.tsx#L8",
|
||||
path: "C:/repo/my dir/app.tsx",
|
||||
lineStart: 8,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps literal percent signs in raw paths that are not hrefs", () => {
|
||||
// Inline-code and linkified tokens are raw file paths: a real file named
|
||||
// "my%20file.md" must not be decoded into "my file.md".
|
||||
expect(
|
||||
parseAssistantFileLink("docs/my%20file.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my%20file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/my%20file.md:3", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/my%20file.md:3",
|
||||
path: "/tmp/my%20file.md",
|
||||
lineStart: 3,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-ASCII characters in raw absolute paths", () => {
|
||||
// `new URL` percent-encodes non-ASCII, so raw paths must not be routed
|
||||
// through URL parsing — `/tmp/开户.md` must round-trip unchanged.
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/开户.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/开户.md",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/开户.md#L4", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/开户.md#L4",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: 4,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
@@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("encoded filename line markers", () => {
|
||||
it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
|
||||
"keeps encoded line syntax in the filename %s",
|
||||
(href) => {
|
||||
expect(
|
||||
parseAssistantFileLink(href, {
|
||||
workspaceRoot: "/workspace",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
|
||||
expect(
|
||||
parseAssistantFileLink(href, {
|
||||
workspaceRoot: "/workspace",
|
||||
decodeHref: true,
|
||||
})?.lineStart,
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
|
||||
|
||||
export interface AssistantHrefParseOptions {
|
||||
workspaceRoot?: string;
|
||||
/**
|
||||
* True only when `value` is a markdown href — the renderer percent-encodes
|
||||
* non-ASCII and space characters, so the path must be decoded. Raw path
|
||||
* text (inline code, linkified tokens) is not encoded; decoding it would
|
||||
* send a real file named `my%20file.md` to `my file.md`.
|
||||
*/
|
||||
decodeHref?: boolean;
|
||||
}
|
||||
|
||||
export type AssistantFileLinkClassification =
|
||||
@@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
|
||||
function parseAssistantInlinePathLink(
|
||||
value: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): InlinePathTarget | null {
|
||||
const inlinePathTarget = parseInlinePathToken(value);
|
||||
if (!inlinePathTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(inlinePathTarget.path);
|
||||
let pathPart = inlinePathTarget.path;
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
@@ -277,7 +291,7 @@ export function classifyAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||
return {
|
||||
kind: "ambiguousFileCandidate",
|
||||
target,
|
||||
@@ -308,32 +322,19 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
|
||||
if (inlinePathTarget) {
|
||||
return inlinePathTarget;
|
||||
}
|
||||
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (windowsPathMatch) {
|
||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
|
||||
if (windowsTarget) {
|
||||
return windowsTarget;
|
||||
}
|
||||
|
||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
decodeHref: options.decodeHref,
|
||||
});
|
||||
if (relativeTarget) {
|
||||
return relativeTarget;
|
||||
@@ -343,19 +344,33 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||
} catch {
|
||||
return null;
|
||||
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
|
||||
// normalize before decode. Raw paths must not: `new URL` percent-encodes
|
||||
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
|
||||
// still-encoded name.
|
||||
let pathPart: string;
|
||||
let hashPart: string;
|
||||
if (options.decodeHref) {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
|
||||
hashPart = parsedUrl.hash;
|
||||
} else {
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
|
||||
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
const lines = parseLineFragment(hashPart);
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
@@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean {
|
||||
return isPlausibleAssistantLocalPath(path);
|
||||
}
|
||||
|
||||
function parseWindowsFileLink(
|
||||
trimmed: string,
|
||||
raw: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): InlinePathTarget | null {
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (!windowsPathMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathPart = windowsPathMatch[1] ?? "";
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceRelativeFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
const parsed = parseLocalPathParts(value);
|
||||
const parsed = parseLocalPathParts(value, options);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return null;
|
||||
}
|
||||
@@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink(
|
||||
|
||||
function parseLocalPathParts(
|
||||
value: string,
|
||||
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): {
|
||||
path: string;
|
||||
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
||||
} | null {
|
||||
const normalized = normalizePathToken(value);
|
||||
if (!normalized || normalized.includes("?")) {
|
||||
return null;
|
||||
@@ -436,14 +486,19 @@ function parseLocalPathParts(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Split literal line markers before decoding; encoded punctuation belongs
|
||||
// to the filename. Raw inline-code/path text stays untouched.
|
||||
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||
if (inlinePathTarget) {
|
||||
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
|
||||
let pathPart = inlinePathTarget.path;
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
if (!isPlausibleAssistantLocalPath(pathPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: inlinePathTarget.path,
|
||||
path: pathPart,
|
||||
lines: {
|
||||
lineStart: inlinePathTarget.lineStart,
|
||||
lineEnd: inlinePathTarget.lineEnd,
|
||||
@@ -455,12 +510,17 @@ function parseLocalPathParts(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPlausibleAssistantLocalPath(beforeHash)) {
|
||||
let decodedBeforeHash = beforeHash;
|
||||
if (options.decodeHref) {
|
||||
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
||||
}
|
||||
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
||||
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: beforeHash,
|
||||
path: decodedBeforeHash,
|
||||
lines: fragmentLines,
|
||||
};
|
||||
}
|
||||
@@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean {
|
||||
function isAmbiguousWorkspaceCandidate(
|
||||
value: string,
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot?: string,
|
||||
options: AssistantHrefParseOptions = {},
|
||||
): boolean {
|
||||
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseLocalPathParts(value);
|
||||
const parsed = parseLocalPathParts(value, options);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
@@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
|
||||
try {
|
||||
await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
|
||||
const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
|
||||
const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
|
||||
expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
|
||||
const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
|
||||
expect(resolution.kind).toBe("resolved");
|
||||
if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
|
||||
throw new Error("Expected a directly resolved file link");
|
||||
}
|
||||
expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
|
||||
} finally {
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
@@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
|
||||
};
|
||||
|
||||
describe("classifyForResolution", () => {
|
||||
it.each([
|
||||
["C:/repo/my%20file.md", undefined],
|
||||
["C:\\repo\\my%20file.md", undefined],
|
||||
["C:/repo/my%20file.md:12", 12],
|
||||
["C:\\repo\\my%20file.md:12", 12],
|
||||
])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
|
||||
for (const text of [href, undefined]) {
|
||||
const result = classifyForResolution(
|
||||
{ href, text, sourceType: "inline-code" },
|
||||
{ workspaceRoot: "C:/repo" },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
|
||||
});
|
||||
}
|
||||
expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
|
||||
});
|
||||
});
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
|
||||
@@ -165,6 +209,46 @@ describe("classifyForResolution", () => {
|
||||
|
||||
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
||||
});
|
||||
|
||||
it("decodes percent-encoded markdown hrefs", () => {
|
||||
const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps literal percent signs in inline-code paths", () => {
|
||||
const result = classifyForResolution(
|
||||
{
|
||||
href: "docs/my%20file.md",
|
||||
text: "docs/my%20file.md",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: "docs/my%20file.md",
|
||||
token: "docs/my%20file.md",
|
||||
target: {
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my%20file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDaemonResolution", () => {
|
||||
|
||||
@@ -108,13 +108,15 @@ export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
): AssistantFileLinkResolution {
|
||||
const token = getAssistantFileLinkToken(source).trim();
|
||||
const tokenInfo = getAssistantFileLinkTokenInfo(source);
|
||||
const token = tokenInfo.token.trim();
|
||||
if (!token) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
decodeHref: tokenInfo.fromHref,
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
@@ -148,14 +150,26 @@ export function classifyForResolution(
|
||||
}
|
||||
|
||||
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
|
||||
return getAssistantFileLinkTokenInfo(source).token;
|
||||
}
|
||||
|
||||
// Markdown link hrefs are percent-encoded by the renderer; inline-code and
|
||||
// linkified text are raw file paths that must not be decoded.
|
||||
function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
|
||||
token: string;
|
||||
fromHref: boolean;
|
||||
} {
|
||||
if (source.sourceType === "inline-code") {
|
||||
return { token: source.text?.trim() || source.href, fromHref: false };
|
||||
}
|
||||
if (isLinkifiedSource(source)) {
|
||||
const text = source.text?.trim();
|
||||
if (text && isFileLookingAssistantToken(text)) {
|
||||
return text;
|
||||
return { token: text, fromHref: false };
|
||||
}
|
||||
}
|
||||
|
||||
return source.href;
|
||||
return { token: source.href, fromHref: true };
|
||||
}
|
||||
|
||||
export function getAmbiguousSuggestionQuery(
|
||||
|
||||
@@ -29,14 +29,21 @@ export interface AssistantFileLinkActions {
|
||||
canResolveFile(source: AssistantFileLinkSource): boolean;
|
||||
}
|
||||
|
||||
interface AssistantFileLinkLookupKey {
|
||||
query: string;
|
||||
path: string;
|
||||
lineStart: number | null;
|
||||
lineEnd: number | null;
|
||||
}
|
||||
|
||||
type AssistantFileLinkQueryKey = readonly [
|
||||
"assistantFileLink",
|
||||
string | null,
|
||||
string | null,
|
||||
string,
|
||||
AssistantFileLinkLookupKey | null,
|
||||
];
|
||||
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const { t } = useTranslation();
|
||||
@@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
token: resolution.token,
|
||||
resolution,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
@@ -179,7 +186,7 @@ function openAssistantFileLink(input: {
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
token: capturedResolution.token,
|
||||
resolution: capturedResolution,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
@@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
token: string;
|
||||
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
{
|
||||
query: input.resolution.ambiguousQuery,
|
||||
path: input.resolution.target.path,
|
||||
lineStart: input.resolution.target.lineStart ?? null,
|
||||
lineEnd: input.resolution.target.lineEnd ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBottomSheetVisibleContentHeight,
|
||||
getCompactSheetSafeAreaPadding,
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
|
||||
describe("getCompactSheetSafeAreaPadding", () => {
|
||||
it("assigns safe clearance to the footer independently of decorative padding", () => {
|
||||
@@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => {
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBottomSheetVisibleContentHeight", () => {
|
||||
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
|
||||
const layout = {
|
||||
containerHeight: 874,
|
||||
contentPosition: 88,
|
||||
handleHeight: 24,
|
||||
keyboardHeight: 344,
|
||||
};
|
||||
|
||||
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
|
||||
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding {
|
||||
footerPaddingBottom?: number;
|
||||
}
|
||||
|
||||
interface BottomSheetVisibleContentHeightInput {
|
||||
containerHeight: number;
|
||||
contentPosition: number;
|
||||
handleHeight: number;
|
||||
keyboardHeight: number;
|
||||
isKeyboardVisible: boolean;
|
||||
}
|
||||
|
||||
export function getBottomSheetVisibleContentHeight({
|
||||
containerHeight,
|
||||
contentPosition,
|
||||
handleHeight,
|
||||
keyboardHeight,
|
||||
isKeyboardVisible,
|
||||
}: BottomSheetVisibleContentHeightInput): number {
|
||||
"worklet";
|
||||
return Math.max(
|
||||
0,
|
||||
containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCompactSheetSafeAreaPadding({
|
||||
isCompact,
|
||||
isKeyboardVisible,
|
||||
|
||||
@@ -12,22 +12,16 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
SheetVisibleFrame,
|
||||
type ContextBridge,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
getBottomSheetVisibleContentHeight,
|
||||
getCompactSheetSafeAreaPadding,
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
@@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
bottomSheetVisibleContent: {
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
bottomSheetVisibleScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -263,40 +253,6 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
|
||||
return <View style={[styles.sheetContent, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
|
||||
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
|
||||
useBottomSheetInternal();
|
||||
const visibleContentStyle = useAnimatedStyle(() => {
|
||||
const { containerHeight, handleHeight } = animatedLayoutState.get();
|
||||
if (containerHeight < 0 || handleHeight < 0) {
|
||||
return { height: 0 };
|
||||
}
|
||||
|
||||
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
|
||||
const contentPosition =
|
||||
initialDetentPosition == null
|
||||
? animatedPosition.get()
|
||||
: Math.min(animatedPosition.get(), initialDetentPosition);
|
||||
|
||||
const keyboardState = animatedKeyboardState.get();
|
||||
return {
|
||||
height: getBottomSheetVisibleContentHeight({
|
||||
containerHeight,
|
||||
contentPosition,
|
||||
handleHeight,
|
||||
keyboardHeight: keyboardState.heightWithinContainer,
|
||||
isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN,
|
||||
}),
|
||||
};
|
||||
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetHeaderView({
|
||||
header,
|
||||
onClose,
|
||||
@@ -471,8 +427,6 @@ export interface AdaptiveModalSheetProps {
|
||||
bodyStyle?: StyleProp<ViewStyle>;
|
||||
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
sizeContentToCurrentSnapPoint?: boolean;
|
||||
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
@@ -493,7 +447,6 @@ export function AdaptiveModalSheet({
|
||||
presentation,
|
||||
contentStyle,
|
||||
bodyStyle,
|
||||
sizeContentToCurrentSnapPoint = true,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -519,11 +472,15 @@ export function AdaptiveModalSheet({
|
||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const footerView = footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null;
|
||||
const footerView = useMemo(
|
||||
() =>
|
||||
footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null,
|
||||
[footer, footerClearanceStyle, footerContainerStyle],
|
||||
);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
@@ -628,7 +585,6 @@ export function AdaptiveModalSheet({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{footerView}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -650,11 +606,7 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
{sizeContentToCurrentSnapPoint ? (
|
||||
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
|
||||
) : (
|
||||
sheetContent
|
||||
)}
|
||||
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useId, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
|
||||
const FADE_OVERHANG = 24;
|
||||
|
||||
interface FloatingHeaderLayoutProps {
|
||||
header: ReactNode;
|
||||
/** Float only when the content supports visual-top clearance. */
|
||||
floating: boolean;
|
||||
children: (topOverlayInset: number) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks a header above content, or floats it over the content behind a fade. Children keep the
|
||||
* same tree position in both modes, so toggling `floating` never remounts them.
|
||||
*/
|
||||
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{floating ? null : header}
|
||||
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
|
||||
{floating ? (
|
||||
<View style={styles.overlay} pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
||||
<FloatingHeaderFade />
|
||||
{header}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) {
|
||||
return (
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
|
||||
some engines and leaves a grey fringe. */}
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="50%" stopColor={color} stopOpacity={0.97} />
|
||||
<Stop offset="75%" stopColor={color} stopOpacity={0.8} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg);
|
||||
const workspaceSurfaceColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surfaceWorkspace,
|
||||
});
|
||||
|
||||
function FloatingHeaderFade() {
|
||||
// React-generated ids contain characters that are invalid inside SVG fragment references.
|
||||
const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
return (
|
||||
<View style={styles.fade} pointerEvents="none">
|
||||
<ThemedFloatingHeaderFadeSvg
|
||||
gradientId={gradientId}
|
||||
uniProps={workspaceSurfaceColorMapping}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
fade: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -FADE_OVERHANG,
|
||||
},
|
||||
});
|
||||
@@ -6,10 +6,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { ScreenTitle } from "./screen-title";
|
||||
import { CompactHeader } from "./compact-header";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface BackHeaderProps {
|
||||
title?: string;
|
||||
titleAccessory?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
onBack?: () => void;
|
||||
}
|
||||
@@ -18,7 +19,15 @@ function goBack(): void {
|
||||
router.back();
|
||||
}
|
||||
|
||||
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
|
||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
if (isCompact) {
|
||||
return <CompactHeader navigation="back" title={title} onBack={onBack} actions={rightContent} />;
|
||||
}
|
||||
return <DesktopBackHeader title={title} rightContent={rightContent} onBack={onBack} />;
|
||||
}
|
||||
|
||||
function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const handleBack = useCallback(() => {
|
||||
@@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
|
||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{title && <ScreenTitle>{title}</ScreenTitle>}
|
||||
{titleAccessory}
|
||||
</>
|
||||
}
|
||||
right={rightContent}
|
||||
@@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
backButton: {
|
||||
padding: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
|
||||
import {
|
||||
IconButtonChromeProvider,
|
||||
iconButtonChromeGlyphSize,
|
||||
mutedIconColorMapping,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
const ThemedArrowLeft = withUnistyles(ArrowLeft);
|
||||
|
||||
const ISLAND_PADDING = 7;
|
||||
const NO_SHORTCUT: ShortcutKey[] = [];
|
||||
|
||||
interface CompactHeaderProps {
|
||||
/** The leading island: the sidebar menu, or a back button. */
|
||||
navigation: "menu" | "back";
|
||||
/** Back button action. Defaults to navigating back. */
|
||||
onBack?: () => void;
|
||||
title?: string;
|
||||
titleTestID?: string;
|
||||
/** A line under the title. */
|
||||
subtitle?: ReactNode;
|
||||
/** Show a placeholder instead of the title while it loads. */
|
||||
loading?: boolean;
|
||||
/** Icon actions inherit the island chrome through the shared button primitives. */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone header: the navigation button and the screen's actions sit in islands, with the title
|
||||
* left-aligned between them. It has no background of its own, so it reads the same in flow and floating
|
||||
* over content (see `FloatingHeaderLayout`). Its height matches the old flat header, so anything
|
||||
* offset by the header height still lines up.
|
||||
*/
|
||||
export function CompactHeader({
|
||||
navigation,
|
||||
onBack,
|
||||
title,
|
||||
titleTestID,
|
||||
subtitle,
|
||||
loading = false,
|
||||
actions,
|
||||
}: CompactHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const rootStyle = useMemo(
|
||||
() => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
|
||||
[insets.top],
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<View style={rootStyle}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.island}>
|
||||
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
|
||||
</View>
|
||||
<View style={styles.titleGroup}>
|
||||
<CompactHeaderTitle
|
||||
loading={loading}
|
||||
title={title}
|
||||
titleTestID={titleTestID}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
</View>
|
||||
{actions ? <View style={styles.island}>{actions}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
</IconButtonChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactHeaderTitle({
|
||||
loading,
|
||||
title,
|
||||
titleTestID,
|
||||
subtitle,
|
||||
}: Pick<CompactHeaderProps, "title" | "titleTestID" | "subtitle"> & { loading: boolean }) {
|
||||
if (loading) {
|
||||
return <View style={styles.titleSkeleton} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{title ? (
|
||||
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
{subtitle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BackButton({ onBack }: { onBack?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const handlePress = useCallback(() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
}, [onBack]);
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
onPress={handlePress}
|
||||
tooltipLabel={t("common.actions.back")}
|
||||
tooltipKeys={NO_SHORTCUT}
|
||||
tooltipSide="right"
|
||||
testID="header-back-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("common.actions.back")}
|
||||
>
|
||||
<ThemedArrowLeft
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
uniProps={mutedIconColorMapping}
|
||||
/>
|
||||
</HeaderToggleButton>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
row: {
|
||||
height: HEADER_INNER_HEIGHT_MOBILE,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
titleGroup: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
// Both title lines receive the remaining width; text alignment does not size the slot.
|
||||
alignItems: "stretch",
|
||||
},
|
||||
island: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: ISLAND_PADDING,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
...theme.shadow.sm,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "left",
|
||||
},
|
||||
titleSkeleton: {
|
||||
width: 140,
|
||||
maxWidth: "100%",
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
opacity: 0.25,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -7,7 +7,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
iconButtonChromeFrameStyle,
|
||||
iconButtonChromeStyle,
|
||||
useIconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
|
||||
interface HeaderToggleButtonState {
|
||||
@@ -46,17 +46,11 @@ export function HeaderToggleButton({
|
||||
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
|
||||
: null;
|
||||
|
||||
const combinedStyle = useMemo(
|
||||
() =>
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
|
||||
iconButtonChromeStyle({
|
||||
size: "large",
|
||||
state: { hovered: Boolean(hovered), pressed },
|
||||
disabled: Boolean(disabled),
|
||||
style,
|
||||
}),
|
||||
[disabled, style],
|
||||
);
|
||||
const combinedStyle = useIconButtonChromeStyle({
|
||||
size: "large",
|
||||
disabled: Boolean(disabled),
|
||||
style,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { ScreenTitle } from "./screen-title";
|
||||
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { CompactHeader } from "./compact-header";
|
||||
import { SidebarMenuToggle } from "./sidebar-menu-toggle";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
|
||||
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
@@ -18,130 +12,11 @@ interface MenuHeaderProps {
|
||||
borderless?: boolean;
|
||||
}
|
||||
|
||||
interface SidebarMenuToggleProps {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
tooltipSide?: "left" | "right" | "top" | "bottom";
|
||||
testID?: string;
|
||||
nativeID?: string;
|
||||
}
|
||||
|
||||
const MOBILE_MENU_LINE_WIDTH = 16;
|
||||
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
|
||||
const MOBILE_MENU_LINE_HEIGHT = 1.5;
|
||||
|
||||
function MobileMenuIcon({ color }: { color: string }) {
|
||||
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
|
||||
const shortLineStyle = useMemo(
|
||||
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }],
|
||||
[color],
|
||||
);
|
||||
return (
|
||||
<View style={styles.mobileMenuIcon} pointerEvents="none">
|
||||
<View style={lineStyle} />
|
||||
<View style={lineStyle} />
|
||||
<View style={shortLineStyle} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuToggleButton({
|
||||
isMobile,
|
||||
extraMutedIdleIcon = false,
|
||||
resolvedStyle,
|
||||
tooltipSide = "right",
|
||||
testID = "menu-button",
|
||||
nativeID = "menu-button",
|
||||
}: Omit<SidebarMenuToggleProps, "style"> & {
|
||||
isMobile: boolean;
|
||||
extraMutedIdleIcon?: boolean;
|
||||
resolvedStyle: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||
const toggleShortcutKeys = useMemo(
|
||||
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
toggleAgentListForLayout({ isCompact: isMobile });
|
||||
}, [toggleAgentListForLayout, isMobile]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
|
||||
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
onPress={handlePress}
|
||||
tooltipLabel={t("shell.menu.toggleSidebar")}
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide={tooltipSide}
|
||||
testID={testID}
|
||||
nativeID={nativeID}
|
||||
style={resolvedStyle}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
|
||||
accessibilityState={accessibilityState}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MobileMenuIcon
|
||||
color={
|
||||
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PanelLeft
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
color={
|
||||
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</HeaderToggleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
||||
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
|
||||
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
|
||||
const placeholderStyle = useMemo(
|
||||
() => [headerIconSlotStyle.slot, resolvedStyle],
|
||||
[resolvedStyle],
|
||||
);
|
||||
|
||||
if (!isMobile && !ownsTopLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isMobile && hasTopLeftWindowControls) {
|
||||
return (
|
||||
<View pointerEvents="none" style={placeholderStyle}>
|
||||
<View style={styles.desktopMenuIconSpace} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
|
||||
}
|
||||
|
||||
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
|
||||
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
|
||||
return (
|
||||
<SidebarMenuToggleButton
|
||||
{...props}
|
||||
isMobile={false}
|
||||
extraMutedIdleIcon
|
||||
resolvedStyle={resolvedStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
if (isCompact) {
|
||||
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
|
||||
}
|
||||
return (
|
||||
<ScreenHeader
|
||||
left={
|
||||
@@ -158,31 +33,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
leadingToggle: {
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
md: -theme.spacing[2],
|
||||
},
|
||||
},
|
||||
left: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
mobileMenuIcon: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: 12,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
desktopMenuIconSpace: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
},
|
||||
mobileMenuLine: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: MOBILE_MENU_LINE_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
mobileMenuLineShort: {
|
||||
width: MOBILE_MENU_LINE_SHORT_WIDTH,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
|
||||
import {
|
||||
extraMutedIconColorMapping,
|
||||
iconButtonChromeGlyphSize,
|
||||
mutedIconColorMapping,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
|
||||
const ThemedPanelLeft = withUnistyles(PanelLeft);
|
||||
|
||||
interface SidebarMenuToggleProps {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
tooltipSide?: "left" | "right" | "top" | "bottom";
|
||||
testID?: string;
|
||||
nativeID?: string;
|
||||
}
|
||||
|
||||
const MOBILE_MENU_LINE_WIDTH = 16;
|
||||
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
|
||||
const MOBILE_MENU_LINE_HEIGHT = 1.5;
|
||||
|
||||
function MobileMenuIcon({ extraMuted }: { extraMuted: boolean }) {
|
||||
const color = extraMuted ? styles.mobileMenuLineExtraMuted : styles.mobileMenuLineMuted;
|
||||
const lineStyle = useMemo(() => [styles.mobileMenuLine, color], [color]);
|
||||
const shortLineStyle = useMemo(
|
||||
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, color],
|
||||
[color],
|
||||
);
|
||||
return (
|
||||
<View style={styles.mobileMenuIcon} pointerEvents="none">
|
||||
<View style={lineStyle} />
|
||||
<View style={lineStyle} />
|
||||
<View style={shortLineStyle} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuToggleButton({
|
||||
isMobile,
|
||||
extraMutedIdleIcon = false,
|
||||
resolvedStyle,
|
||||
tooltipSide = "right",
|
||||
testID = "menu-button",
|
||||
nativeID = "menu-button",
|
||||
}: Omit<SidebarMenuToggleProps, "style"> & {
|
||||
isMobile: boolean;
|
||||
extraMutedIdleIcon?: boolean;
|
||||
resolvedStyle: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||
const toggleShortcutKeys = useMemo(
|
||||
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
toggleAgentListForLayout({ isCompact: isMobile });
|
||||
}, [toggleAgentListForLayout, isMobile]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
|
||||
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
onPress={handlePress}
|
||||
tooltipLabel={t("shell.menu.toggleSidebar")}
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide={tooltipSide}
|
||||
testID={testID}
|
||||
nativeID={nativeID}
|
||||
style={resolvedStyle}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
|
||||
accessibilityState={accessibilityState}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MobileMenuIcon extraMuted={extraMutedIdleIcon} />
|
||||
) : (
|
||||
<ThemedPanelLeft
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
uniProps={extraMutedIdleIcon ? extraMutedIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
)}
|
||||
</HeaderToggleButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
|
||||
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
|
||||
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
|
||||
const placeholderStyle = useMemo(
|
||||
() => [headerIconSlotStyle.slot, resolvedStyle],
|
||||
[resolvedStyle],
|
||||
);
|
||||
|
||||
if (!isMobile && !ownsTopLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isMobile && hasTopLeftWindowControls) {
|
||||
return (
|
||||
<View pointerEvents="none" style={placeholderStyle}>
|
||||
<View style={styles.desktopMenuIconSpace} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
|
||||
}
|
||||
|
||||
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
|
||||
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
|
||||
return (
|
||||
<SidebarMenuToggleButton
|
||||
{...props}
|
||||
isMobile={false}
|
||||
extraMutedIdleIcon
|
||||
resolvedStyle={resolvedStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
leadingToggle: {
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
md: -theme.spacing[2],
|
||||
},
|
||||
},
|
||||
mobileMenuIcon: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: 12,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
desktopMenuIconSpace: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
},
|
||||
mobileMenuLine: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: MOBILE_MENU_LINE_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
mobileMenuLineMuted: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
mobileMenuLineExtraMuted: {
|
||||
backgroundColor: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
mobileMenuLineShort: {
|
||||
width: MOBILE_MENU_LINE_SHORT_WIDTH,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,13 @@
|
||||
import { router } from "expo-router";
|
||||
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
|
||||
import {
|
||||
CircleGauge,
|
||||
FolderPlus,
|
||||
GitBranch,
|
||||
Server,
|
||||
Settings,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -26,7 +34,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -37,6 +45,7 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { density } from "@/styles/density";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
@@ -280,30 +289,25 @@ function FooterIconButton({
|
||||
onPress,
|
||||
testID,
|
||||
label,
|
||||
icon: Icon,
|
||||
icon,
|
||||
iconSizeAdjustment = 0,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
icon: LucideIcon;
|
||||
/** Only for a glyph that reads larger than the others at the same size. */
|
||||
iconSizeAdjustment?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton(isCompact)}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
@@ -313,9 +317,11 @@ function FooterIconButton({
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize + iconSizeAdjustment}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
<DensityIcon
|
||||
icon={icon}
|
||||
size="md"
|
||||
sizeAdjustment={iconSizeAdjustment}
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -328,12 +334,10 @@ function FooterIconButton({
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
label,
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
label: string;
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
@@ -377,7 +381,6 @@ function SidebarHostPicker({
|
||||
icon={Server}
|
||||
// Server's two boxes fill more of the square than the other glyphs.
|
||||
iconSizeAdjustment={-1}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
);
|
||||
@@ -399,7 +402,6 @@ function IconTooltipContent({
|
||||
}
|
||||
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
labels,
|
||||
@@ -407,7 +409,6 @@ function SidebarFooter({
|
||||
handleOpenHostSettings,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
handleOpenProject: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
@@ -436,11 +437,9 @@ function SidebarFooter({
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarUsageIcon label={labels.usage} theme={theme} />
|
||||
<SidebarUsageIcon label={labels.usage} />
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
@@ -453,7 +452,6 @@ function SidebarFooter({
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -461,7 +459,7 @@ function SidebarFooter({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
|
||||
function SidebarUsageIcon({ label }: { label: string }) {
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
return (
|
||||
<FooterIconButton
|
||||
@@ -469,7 +467,6 @@ function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme
|
||||
testID="sidebar-usage-icon"
|
||||
label={label}
|
||||
icon={CircleGauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -612,7 +609,6 @@ function MobileSidebar({
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
@@ -789,7 +785,6 @@ function DesktopSidebar({
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
@@ -957,14 +952,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
footerIconButton: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
}),
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -391,6 +391,43 @@ describe("splitHtmlishMarkdown", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps table rows after a comparison that only looks like a tag", () => {
|
||||
const source = [
|
||||
"| ID | Note | Status |",
|
||||
"|---|---|---|",
|
||||
"| a-1 | accepts 1<size<2 only | open |",
|
||||
"| a-2 | second row | open |",
|
||||
"",
|
||||
"Later x -> y.",
|
||||
].join("\n");
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it.each(["See <https://example.com/docs> for more.", "Stop when a<b", "Keep </ b> and a <b"])(
|
||||
"keeps %j literal because it is not HTML",
|
||||
(source) => {
|
||||
expect(normalizeHtmlishMarkdown(source)).toBe(source);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"Call As<Integer>() before limits<DWORD>::max()",
|
||||
"<Kbd Data-Key='K'>Ctrl</Kbd> then <Foo/>",
|
||||
])("echoes unknown tags in %j as written", (source) => {
|
||||
expect(normalizeHtmlishMarkdown(source)).toBe(source);
|
||||
});
|
||||
|
||||
it("rebuilds an unknown tag that a code span splits", () => {
|
||||
expect(normalizeHtmlishMarkdown('<Tip title="`x`">hi</Tip>')).toBe("`x`<tip title>hi</Tip>");
|
||||
});
|
||||
|
||||
it("still reads tags and comments next to literal angle brackets", () => {
|
||||
expect(normalizeHtmlishMarkdown("1<n<2 <b>bold</b><!-- hidden --> 3<4")).toBe(
|
||||
"1<n<2 **bold** 3<4",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when details are unclosed", () => {
|
||||
const source = "<details><summary>Open</summary>Still open";
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
|
||||
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
|
||||
const HTML_TAG_RE = new RegExp(
|
||||
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
|
||||
"y",
|
||||
);
|
||||
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
|
||||
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
||||
b: ["**", "**"],
|
||||
@@ -373,14 +379,16 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
output += renderHtmlTag(token, children);
|
||||
const close = tokens[closeIndex];
|
||||
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
|
||||
output += renderHtmlTag(token, children, closeRaw);
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
|
||||
if (token.name === "a") {
|
||||
return renderLinkToken(token, children);
|
||||
}
|
||||
@@ -400,7 +408,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
if (token.name === "p" || token.name === "div") {
|
||||
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
|
||||
}
|
||||
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
|
||||
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
|
||||
}
|
||||
|
||||
function renderTableTokens(tokens: HtmlToken[]): string {
|
||||
@@ -606,7 +614,15 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||
let stripNextLeadingLineBreak = false;
|
||||
let skippedLength = 0;
|
||||
let writtenLength = 0;
|
||||
let chunkStart = 0;
|
||||
let parser!: Parser;
|
||||
// Unknown tags render as text, so keep them as written: the parser lowercases names
|
||||
// and rewrites attributes. A tag split by a protected range has no single slice.
|
||||
const readRawTag = (): string | null =>
|
||||
parser.startIndex < chunkStart
|
||||
? null
|
||||
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
|
||||
parser = new Parser(
|
||||
{
|
||||
onopentag(name, attributes, isImplied) {
|
||||
@@ -620,7 +636,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
||||
closing: false,
|
||||
selfClosing,
|
||||
attributes,
|
||||
raw: renderStartTag(name, attributes),
|
||||
raw: readRawTag() ?? renderStartTag(name, attributes),
|
||||
});
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
@@ -633,7 +649,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
||||
closing: true,
|
||||
selfClosing: false,
|
||||
attributes: {},
|
||||
raw: `</${name}>`,
|
||||
raw: readRawTag() ?? `</${name}>`,
|
||||
});
|
||||
},
|
||||
ontext(value) {
|
||||
@@ -660,6 +676,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
||||
);
|
||||
return {
|
||||
write(chunk) {
|
||||
chunkStart = writtenLength;
|
||||
writtenLength += chunk.length;
|
||||
parser.write(chunk);
|
||||
},
|
||||
skip(length) {
|
||||
@@ -698,7 +716,58 @@ function escapeAttribute(value: string): string {
|
||||
|
||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const fencedRanges = getFencedCodeRanges(source);
|
||||
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
|
||||
const codeRanges = mergeProtectedRanges([
|
||||
...fencedRanges,
|
||||
...getInlineCodeRanges(source, fencedRanges),
|
||||
]);
|
||||
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
|
||||
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
|
||||
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
|
||||
*/
|
||||
function getLiteralAngleBracketRanges(
|
||||
source: string,
|
||||
codeRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
let codeIndex = 0;
|
||||
// Reused across comment openers so unclosed comments stay linear.
|
||||
let commentClose = -1;
|
||||
let index = source.indexOf("<");
|
||||
|
||||
while (index !== -1) {
|
||||
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
|
||||
codeIndex += 1;
|
||||
}
|
||||
const code = codeRanges[codeIndex];
|
||||
if (code && code.start <= index) {
|
||||
index = source.indexOf("<", code.end);
|
||||
continue;
|
||||
}
|
||||
|
||||
let htmlEnd: number | null;
|
||||
if (source.startsWith("<!--", index)) {
|
||||
if (commentClose !== source.length && commentClose < index + 2) {
|
||||
const close = source.indexOf("-->", index + 2);
|
||||
commentClose = close === -1 ? source.length : close;
|
||||
}
|
||||
htmlEnd = commentClose === source.length ? null : commentClose + 3;
|
||||
} else {
|
||||
HTML_TAG_RE.lastIndex = index;
|
||||
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
|
||||
}
|
||||
|
||||
if (htmlEnd === null) {
|
||||
ranges.push({ start: index, end: index + 1 });
|
||||
htmlEnd = index + 1;
|
||||
}
|
||||
index = source.indexOf("<", htmlEnd);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||
|
||||
@@ -126,9 +126,6 @@ export function ProjectEditSheet({
|
||||
onClose={handleClose}
|
||||
footer={footer}
|
||||
desktopMaxWidth={440}
|
||||
// Bound the compact scroller to the live snap height so the footer stays
|
||||
// on screen instead of being pushed past the bottom of the sheet.
|
||||
sizeContentToCurrentSnapPoint
|
||||
testID="project-edit-sheet"
|
||||
>
|
||||
<Field
|
||||
|
||||
@@ -95,11 +95,11 @@ function CustomModelRow({
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -147,4 +147,29 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps option previews so options can be compared", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which layout?",
|
||||
header: "Layout",
|
||||
options: [
|
||||
{
|
||||
label: "Tabs",
|
||||
description: "One tab per section",
|
||||
preview: "<Tabs>\n <Tab />\n</Tabs>",
|
||||
},
|
||||
{ label: "Single page" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(questions?.[0]?.options).toEqual([
|
||||
{ label: "Tabs", description: "One tab per section", preview: "<Tabs>\n <Tab />\n</Tabs>" },
|
||||
{ label: "Single page", description: undefined, preview: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -46,6 +47,7 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
preview: readOptionalString(o, "preview"),
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -29,6 +30,9 @@ interface QuestionFormCardProps {
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
// Native previews are uncapped: inside the option's Pressable, Android never hands a
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
@@ -133,6 +137,20 @@ function QuestionOptionRow({
|
||||
{option.description ? (
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
// Long lines wrap, as in Claude Code, since most previews are prose.
|
||||
<ScrollableCodeSurface
|
||||
tone="surface0"
|
||||
maxHeight={OPTION_PREVIEW_MAX_HEIGHT}
|
||||
horizontal={false}
|
||||
selectable={false}
|
||||
textStyle={styles.optionPreviewText}
|
||||
testID="question-form-option-preview"
|
||||
>
|
||||
{option.preview}
|
||||
</ScrollableCodeSurface>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -704,6 +722,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
},
|
||||
selectionControl: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
@@ -114,6 +115,7 @@ import {
|
||||
SidebarWorkspaceTrailingContent,
|
||||
useSidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -822,9 +824,10 @@ function NewWorkspaceGhostRow({
|
||||
{({ hovered, pressed }) => (
|
||||
<>
|
||||
<View style={styles.newWorkspaceGhostIconSlot}>
|
||||
<ThemedPlus
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
<DensityIcon
|
||||
icon={Plus}
|
||||
size="sm"
|
||||
color={hovered || pressed ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
@@ -2519,7 +2522,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
|
||||
// pressed fills stay the same box as every other row in the sidebar.
|
||||
newWorkspaceGhostRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[4],
|
||||
@@ -2539,27 +2542,27 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// The width of a workspace row's status slot, so the label lands on the same rail as the
|
||||
// titles above it.
|
||||
newWorkspaceGhostIconSlot: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
newWorkspaceGhostText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
newWorkspaceGhostTextHovered: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
projectRow: {
|
||||
position: "relative",
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -2600,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
@@ -2681,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: theme.spacing[2],
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
|
||||
@@ -2,16 +2,11 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
import { density, densityFontSize } from "@/styles/density";
|
||||
|
||||
export function PinnedSectionHeader({
|
||||
collapsed,
|
||||
@@ -23,7 +18,6 @@ export function PinnedSectionHeader({
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -37,7 +31,11 @@ export function PinnedSectionHeader({
|
||||
<>
|
||||
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
|
||||
{hovered || isNative || isCompact ? (
|
||||
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<DensityIcon
|
||||
icon={collapsed ? ChevronRight : ChevronDown}
|
||||
size="xs"
|
||||
color="foregroundMuted"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
@@ -47,7 +45,7 @@ export function PinnedSectionHeader({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
@@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: densityFontSize(theme, "sm"),
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
getProjectStatusBadgeContent,
|
||||
@@ -35,9 +36,8 @@ const STATUS_BADGE_OFFSET = -4;
|
||||
//
|
||||
// The filled alert occupies the full badge shell so needs-input remains more prominent than
|
||||
// the passive status dots.
|
||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||
const LEADING_SLOT_HEIGHT = 20;
|
||||
const DensityProjectIconView = withUnistyles(ProjectIconView);
|
||||
const projectIconProps = densityIconProps("md", () => ({}));
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
@@ -230,12 +230,12 @@ function ProjectIcon({
|
||||
projectViewKey: string;
|
||||
}) {
|
||||
return (
|
||||
<ProjectIconView
|
||||
<DensityProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectViewKey={projectViewKey}
|
||||
size={ICON_SIZE.md}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
uniProps={projectIconProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
|
||||
return {
|
||||
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
|
||||
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
|
||||
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
|
||||
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
|
||||
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
|
||||
// only as tall as the icon puts it above the title and the kebab — which is why the
|
||||
// workspace status indicator shares the same `rowLineHeight`.
|
||||
projectLeadingVisualSlot: {
|
||||
width: theme.iconSize.md,
|
||||
height: LEADING_SLOT_HEIGHT,
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -284,8 +284,8 @@ const styles = StyleSheet.create((theme) => {
|
||||
// Anchors the corner badge to the icon rather than to the taller slot.
|
||||
projectIconBox: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
},
|
||||
projectIconFallbackText: {
|
||||
fontSize: 9,
|
||||
|
||||
@@ -2,19 +2,11 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronUp = withUnistyles(ChevronUp);
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
|
||||
/**
|
||||
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
|
||||
@@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({
|
||||
{({ hovered, pressed }) => (
|
||||
<>
|
||||
<View style={styles.iconSlot}>
|
||||
{expanded ? (
|
||||
<ThemedChevronUp
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedChevronDown
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
<DensityIcon
|
||||
icon={expanded ? ChevronUp : ChevronDown}
|
||||
size="sm"
|
||||
color={hovered || pressed ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</View>
|
||||
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
|
||||
row: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
@@ -106,21 +92,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// The width of a workspace row's status slot, so the label lands on the same rail as the
|
||||
// titles above it rather than two points to their left.
|
||||
iconSlot: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
|
||||
|
||||
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
|
||||
@@ -49,7 +39,7 @@ interface SidebarHeaderRowProps {
|
||||
}
|
||||
|
||||
export function SidebarHeaderRow({
|
||||
icon: Icon,
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
isActive = false,
|
||||
@@ -64,9 +54,8 @@ export function SidebarHeaderRow({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
|
||||
const isHighlighted = isHovered || isActive;
|
||||
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
|
||||
const iconSize = variant === "header" ? "md" : "sm";
|
||||
|
||||
let right = trailing ?? null;
|
||||
if (right === null && shortcutKeys && isHovered) {
|
||||
@@ -91,10 +80,11 @@ export function SidebarHeaderRow({
|
||||
aria-selected={isActive}
|
||||
style={styles.button}
|
||||
>
|
||||
{ThemedIcon ? (
|
||||
<ThemedIcon
|
||||
{icon ? (
|
||||
<DensityIcon
|
||||
icon={icon}
|
||||
size={iconSize}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
color={isHighlighted ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
) : (
|
||||
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
|
||||
@@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
minHeight: density.tightRowHeight,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
rowHighlighted: {
|
||||
@@ -152,17 +142,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
minHeight: density.tightRowHeight,
|
||||
paddingVertical: theme.spacing[1],
|
||||
// Match the project rows' inner padding so the icons align on one vertical
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
|
||||
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
|
||||
iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
|
||||
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { density } from "@/styles/density";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -32,12 +32,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const ThemedActivity = withUnistyles(Activity);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
const ThemedKeyboard = withUnistyles(Keyboard);
|
||||
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
@@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
@@ -106,15 +103,16 @@ export function SidebarHelpMenu() {
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger(isCompact)}
|
||||
style={styles.trigger}
|
||||
testID="sidebar-help"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("sidebar.help.trigger")}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<ThemedCircleHelp
|
||||
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
<DensityIcon
|
||||
icon={CircleHelp}
|
||||
size="md"
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
@@ -184,14 +182,14 @@ export function SidebarHelpMenu() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
trigger: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
}),
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
|
||||
// Themed icon wrappers
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
}));
|
||||
// One mapping per bucket, resolved through the status-dot producer so a group header and
|
||||
// the rows under it cannot disagree about what "failed" looks like.
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
const failedColorMapping = (theme: Theme) => ({
|
||||
}));
|
||||
const failedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
});
|
||||
const attentionColorMapping = (theme: Theme) => ({
|
||||
}));
|
||||
const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
});
|
||||
const runningColorMapping = (theme: Theme) => ({
|
||||
}));
|
||||
const runningGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
});
|
||||
}));
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
@@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({
|
||||
return <StatusGroupIcon bucket={leading.bucket} />;
|
||||
}
|
||||
if (collapsed) {
|
||||
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
return <ThemedChevronRight uniProps={mutedGroupIconProps} />;
|
||||
}
|
||||
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
return <ThemedChevronDown uniProps={mutedGroupIconProps} />;
|
||||
}
|
||||
|
||||
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
|
||||
switch (bucket) {
|
||||
case "needs_input":
|
||||
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
|
||||
return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
|
||||
case "failed":
|
||||
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
|
||||
return <ThemedCircleX uniProps={failedGroupIconProps} />;
|
||||
case "attention":
|
||||
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
|
||||
return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
|
||||
case "running":
|
||||
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
|
||||
return <ThemedCircleDot uniProps={runningGroupIconProps} />;
|
||||
case "done":
|
||||
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusWorkspaceListContainer: {},
|
||||
statusGroupRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusGroupLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusGroupTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
@@ -27,19 +28,16 @@ import {
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surface0,
|
||||
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
@@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({
|
||||
) : null;
|
||||
}
|
||||
|
||||
let KindIcon: typeof ThemedMonitor;
|
||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
||||
else KindIcon = ThemedFolder;
|
||||
let KindIcon: typeof Monitor;
|
||||
if (workspaceKind === "local_checkout") KindIcon = Monitor;
|
||||
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
|
||||
else KindIcon = Folder;
|
||||
|
||||
const dotColorStyle = getStatusDotColorStyle(bucket);
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
|
||||
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
|
||||
<DensityIcon icon={KindIcon} size="sm" color="foregroundMuted" />
|
||||
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
|
||||
</View>
|
||||
);
|
||||
@@ -482,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: 20,
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
@@ -515,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// to the meta row, so it takes the full width the trailing slot leaves behind.
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
lineHeight: density.rowLineHeight,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { density } from "@/styles/density";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
|
||||
@@ -174,6 +174,7 @@ export function Button({
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
ref?: React.Ref<View>;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
leftIcon?: LeftIcon;
|
||||
|
||||
@@ -160,3 +160,16 @@ describe("combobox above-search ordering", () => {
|
||||
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search-only option text", () => {
|
||||
it("finds hidden search text while keeping title matches first", () => {
|
||||
const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" };
|
||||
const titleMatch = { id: "pr:8", label: "2024 release" };
|
||||
expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([
|
||||
titleMatch,
|
||||
bodyMatch,
|
||||
]);
|
||||
expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]);
|
||||
expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,18 +10,22 @@ export interface ComboboxOptionModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Additional text to match without displaying it in the option row. */
|
||||
searchText?: string;
|
||||
kind?: ComboboxOptionKind;
|
||||
}
|
||||
|
||||
const DESCRIPTION_FALLBACK_TIER = 99;
|
||||
const SECONDARY_TEXT_FALLBACK_TIER = 99;
|
||||
|
||||
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
||||
const best = scoreTextFields(search, [opt.label, opt.id]);
|
||||
if (best) return best;
|
||||
if (!opt.description) return null;
|
||||
const descriptionScore = scoreTextFields(search, [opt.description]);
|
||||
if (!descriptionScore) return null;
|
||||
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
|
||||
const secondaryFields = opt.searchText
|
||||
? [opt.label, opt.id, opt.description ?? "", opt.searchText]
|
||||
: [opt.description ?? ""];
|
||||
const secondaryScore = scoreTextFields(search, secondaryFields);
|
||||
if (!secondaryScore) return null;
|
||||
return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER };
|
||||
}
|
||||
|
||||
export interface BuildVisibleComboboxOptionsInput {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { Check, File, Folder, Search } from "lucide-react-native";
|
||||
@@ -50,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
SheetVisibleFrame,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "./isolated-bottom-sheet-modal";
|
||||
import {
|
||||
@@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
);
|
||||
const { footer, safeAreaBottom } = props;
|
||||
const frameStyle = useMemo(
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
|
||||
[props.safeAreaBottom],
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
||||
[footer, safeAreaBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
||||
[safeAreaBottom],
|
||||
);
|
||||
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
||||
[footer, footerStyle],
|
||||
);
|
||||
|
||||
const body = props.hasChildren ? (
|
||||
@@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
<View style={frameStyle}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</View>
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<View style={frameStyle}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</View>
|
||||
</SheetVisibleFrame>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, type ComponentType } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { densityIconSize, type DensityIconSize } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
|
||||
type IconColor = {
|
||||
[K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never;
|
||||
}[keyof Theme["colors"]];
|
||||
|
||||
function IconGlyph({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
strokeWidth,
|
||||
}: {
|
||||
icon: IconComponent;
|
||||
size: number;
|
||||
color: string;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
return <Icon size={size} color={color} strokeWidth={strokeWidth} />;
|
||||
}
|
||||
|
||||
const ThemedIconGlyph = withUnistyles(IconGlyph);
|
||||
|
||||
/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */
|
||||
export function DensityIcon({
|
||||
icon,
|
||||
size,
|
||||
sizeAdjustment = 0,
|
||||
color,
|
||||
strokeWidth,
|
||||
}: {
|
||||
icon: IconComponent;
|
||||
size: DensityIconSize;
|
||||
/** Only for a glyph that reads larger than others at the same size. */
|
||||
sizeAdjustment?: number;
|
||||
color: IconColor;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
const uniProps = useMemo(
|
||||
() => (theme: Theme, rt: { breakpoint?: string }) => ({
|
||||
size: densityIconSize(rt, size) + sizeAdjustment,
|
||||
color: theme.colors[color],
|
||||
}),
|
||||
[size, sizeAdjustment, color],
|
||||
);
|
||||
return <ThemedIconGlyph icon={icon} strokeWidth={strokeWidth} uniProps={uniProps} />;
|
||||
}
|
||||
@@ -1,47 +1,67 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { Text, type View } from "react-native";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { ICON_SIZE, SPACING } from "@/styles/theme";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
|
||||
const FLOATING_ACTION_BUTTON_SIZE = 56;
|
||||
const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
|
||||
|
||||
/** Space a scrolling surface keeps below its content so the button traps nothing. */
|
||||
export const FLOATING_ACTION_BUTTON_CLEARANCE =
|
||||
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
|
||||
import {
|
||||
FLOATING_ACTION_BUTTON_SIZE,
|
||||
FLOATING_ACTION_BUTTON_INSET,
|
||||
} from "./floating-action-layout";
|
||||
export {
|
||||
FLOATING_ACTION_BUTTON_CLEARANCE,
|
||||
floatingActionsClearance,
|
||||
} from "./floating-action-layout";
|
||||
|
||||
export interface FloatingActionButtonProps {
|
||||
icon: LucideIcon;
|
||||
/** Names the action; the button carries no label. */
|
||||
/** Names the action for assistive technology. */
|
||||
accessibilityLabel: string;
|
||||
onPress: () => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
bottomInset?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one floating action a scrolling surface can offer. It pins itself to the
|
||||
* bottom-right of its nearest positioned ancestor, so the ancestor decides what
|
||||
* the button may cover.
|
||||
* Pins an action to the bottom-right of its nearest positioned ancestor.
|
||||
* The surface reserves scroll clearance and uses bottomInset to separate stacked actions.
|
||||
*/
|
||||
export function FloatingActionButton({
|
||||
icon: Icon,
|
||||
accessibilityLabel,
|
||||
onPress,
|
||||
testID,
|
||||
}: FloatingActionButtonProps) {
|
||||
return (
|
||||
<PressHighlight
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
onPress={onPress}
|
||||
style={styles.button}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
export const FloatingActionButton = forwardRef<View, FloatingActionButtonProps>(
|
||||
function FloatingActionButton(
|
||||
{ icon: Icon, accessibilityLabel, onPress, label, disabled = false, bottomInset = 0, testID },
|
||||
ref,
|
||||
) {
|
||||
const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
|
||||
const buttonStyle = useMemo(
|
||||
() => [
|
||||
styles.button,
|
||||
label && styles.labeled,
|
||||
disabled && styles.disabled,
|
||||
{ bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset },
|
||||
],
|
||||
[bottomInset, disabled, label],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
ref={ref}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={buttonStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
|
||||
{label ? <Text style={styles.label}>{label}</Text> : null}
|
||||
</PressHighlight>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
button: {
|
||||
@@ -56,6 +76,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface3,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
labeled: {
|
||||
width: "auto",
|
||||
minWidth: FLOATING_ACTION_BUTTON_SIZE,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
disabled: { opacity: theme.opacity[50] },
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user