mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802359152e |
@@ -93,6 +93,3 @@ app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
# Project-local Serena configuration and memories.
|
||||
.serena/
|
||||
|
||||
@@ -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, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and 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,12 +151,11 @@ 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. 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
|
||||
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
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
@@ -212,7 +211,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. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 | Stacks tabs; each closes or becomes a tab |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
@@ -23,29 +22,6 @@ 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. Each sheet holds one ordinary
|
||||
`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet
|
||||
(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has
|
||||
a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved
|
||||
work. Opening a sheet as a tab dismisses the sheets above it, since the
|
||||
tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Sheets render in
|
||||
the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need 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.
|
||||
@@ -86,9 +62,7 @@ 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. 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.
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
|
||||
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
|
||||
|
||||
+1
-37
@@ -198,40 +198,6 @@ viewport keeps the last keyboard reservation until the next opening replaces
|
||||
it, so a capped draft keeps one height through every keyboard transition
|
||||
instead of expanding on close and shrinking again on open.
|
||||
|
||||
Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
|
||||
fullscreen` fills the stationary viewport with the composer slot over the
|
||||
still-mounted content, paints its own surface under the safe areas, and turns
|
||||
the surface translation off. The composer's single tree keeps the editing
|
||||
surface at the same position in both modes, so the mounted editor, its native
|
||||
buffer, selection, and focus survive the toggle and Send's clear always reaches
|
||||
the live input. The one remount left is a viewport crossing the compact
|
||||
breakpoint: the app shell swaps its gesture wrapper and the workspace screen
|
||||
mounts tabs in different trees, so every screen below remounts. The draft owner
|
||||
restores the text and the editing session restores the caret and focus intent.
|
||||
New workspace keys its sessions by draft in the app-level store for the same
|
||||
reason. The centered form honors fullscreen by style alone: no centering, no
|
||||
translation, setup fields hidden, slot filling the stationary viewport. The
|
||||
global exit action (Escape, hardware back) is registered only for the active
|
||||
composer: a retained tab keeps its fullscreen editor mounted while hidden, the
|
||||
sheet suspends pane focus, and the composer gives up input from the moment the
|
||||
user asks for the compact agent list or explorer, before the panel's motion
|
||||
settles. Pane focus itself stays put under those panels: the agent panel reads
|
||||
it as "the user is in this chat" for attention clearing, and opening the agent
|
||||
list must not clear the attention it shows.
|
||||
The band beside the inline editor where the fullscreen glyph sits is the input's
|
||||
own right padding, so a tap anywhere in it focuses the editor whether or not the
|
||||
glyph shows. On web the slot is a fixed box over the visible viewport, which is
|
||||
also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
|
||||
height as a bottom margin at move start and end (boundary-time, like the
|
||||
capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
|
||||
frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
|
||||
and without animating the editor's height. The screen that owns the chrome
|
||||
unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
|
||||
active tab and New workspace reads the mounted composer's session. Hosts that
|
||||
render the dock pass the same `ComposerEditingSession` to the `Composer` so the
|
||||
dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
|
||||
registration, focus untouched) and the Android back button leave fullscreen.
|
||||
|
||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
||||
setup fields as the first dock child so they move up under the header as the draft
|
||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
||||
@@ -338,9 +304,7 @@ 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. The one exception is the
|
||||
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
|
||||
whole workspace panel.
|
||||
sibling dialog over an existing iOS presenter.
|
||||
|
||||
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,7 +21,6 @@ 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** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` 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).
|
||||
|
||||
@@ -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. 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.
|
||||
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.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
|
||||
@@ -86,12 +86,6 @@ buildNpmPackage {
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
# Metro's worker runs out of heap transforming the generated
|
||||
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
|
||||
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
|
||||
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
|
||||
# only raises the limit on smaller builders, like desktop-release.yml does.
|
||||
NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@@ -223,7 +223,6 @@ 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,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
async function openAppearance(page: Page) {
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
async function setFontFamily(page: Page, name: string, family: string) {
|
||||
const input = page.getByRole("textbox", { name });
|
||||
await input.fill(family);
|
||||
await input.press("Tab");
|
||||
}
|
||||
|
||||
for (const name of ["Code font family", "Interface font family"]) {
|
||||
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
|
||||
await openAppearance(page);
|
||||
await setFontFamily(page, name, "'Fira Code', monospace");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
|
||||
await setFontFamily(page, name, "'foo");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await setFontFamily(page, name, "");
|
||||
await expect(page.getByRole("textbox", { name })).toHaveValue("");
|
||||
});
|
||||
}
|
||||
|
||||
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
|
||||
);
|
||||
});
|
||||
await openAppearance(page);
|
||||
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
|
||||
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
async function openDefaultAppearance(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ codeFontSize: 12 }));
|
||||
const observations: unknown[] = [];
|
||||
Object.assign(window, { fontRaceObservations: observations });
|
||||
for (const type of ["input", "change", "keydown", "focusin", "focusout"]) {
|
||||
document.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input?.getAttribute?.("aria-label")?.startsWith("Code font")) return;
|
||||
observations.push({
|
||||
type,
|
||||
label: input.getAttribute("aria-label"),
|
||||
value: input.value,
|
||||
key: (event as KeyboardEvent).key,
|
||||
at: performance.now(),
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records)
|
||||
for (const removed of record.removedNodes) {
|
||||
if (
|
||||
removed instanceof Element &&
|
||||
(removed.matches('[aria-label="Code font size"]') ||
|
||||
removed.querySelector('[aria-label="Code font size"]'))
|
||||
)
|
||||
observations.push({ type: "removed-size-input", at: performance.now() });
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
await page.goto("/settings");
|
||||
await openSettingsSection(page, "appearance");
|
||||
}
|
||||
|
||||
async function changeCodeFamilyThenSize(page: Page) {
|
||||
await page.getByLabel("Code font family").fill("Courier New, Courier, monospace");
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await page.getByLabel("Code font size").fill("18");
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
}
|
||||
|
||||
test("keeps the size entered immediately after changing the code font family", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openDefaultAppearance(page);
|
||||
try {
|
||||
await changeCodeFamilyThenSize(page);
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue("18");
|
||||
} finally {
|
||||
await testInfo.attach("font-race", {
|
||||
body: JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
events: (window as Window & { fontRaceObservations: unknown[] }).fontRaceObservations,
|
||||
stored: localStorage.getItem("@paseo:app-settings"),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -9,12 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
@@ -101,6 +96,16 @@ interface PopoverFrameRecorderWindow extends Window {
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
@@ -696,15 +701,19 @@ test.describe("Composer autocomplete", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// The sidebar covers the composer, so the composer gives up its popover while the
|
||||
// sidebar is open and brings it back once the sidebar closes.
|
||||
await openMobileAgentSidebar(page);
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ import {
|
||||
queueComposerWithButton,
|
||||
configureComposerQueueSend,
|
||||
activateRetainedComposerTab,
|
||||
switchComposerTabWithKeyboard,
|
||||
expectWorkspaceHeaderHidden,
|
||||
expectWorkspaceHeaderVisible,
|
||||
recordVisibleComposerGeometry,
|
||||
expectNoCollapsedVisibleComposer,
|
||||
expectAttachmentPill,
|
||||
@@ -30,14 +27,8 @@ import {
|
||||
startRunningMockAgent,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
expectComposerFullscreenFitsVisibleViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
|
||||
@@ -165,36 +156,6 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
}
|
||||
});
|
||||
|
||||
async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
|
||||
async function pausePageClock(page: Page): Promise<void> {
|
||||
const now = Date.now();
|
||||
await page.clock.install({ time: now });
|
||||
await page.clock.pauseAt(now);
|
||||
}
|
||||
|
||||
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
|
||||
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
|
||||
const frame = page.getByTestId("composer-fullscreen");
|
||||
const alertBox = (await frame.getByRole("alert").boundingBox())!;
|
||||
const sendBox = (await page
|
||||
.getByRole("button", { name: "Send message", exact: true })
|
||||
.boundingBox())!;
|
||||
const editorBox = (await composerLocator(page).boundingBox())!;
|
||||
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
|
||||
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
|
||||
}
|
||||
|
||||
async function writeCompactDraft(page: Page, text: string): Promise<void> {
|
||||
await composerLocator(page).fill(text);
|
||||
await expect(composerLocator(page)).toHaveValue(text);
|
||||
@@ -227,107 +188,47 @@ test("compact fullscreen editing preserves the live draft and selection and stay
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
|
||||
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 test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectWorkspaceHeaderHidden(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 test.step("exit restores the inline composer with the fullscreen edits", async () => {
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(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 test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Typed after the fullscreen cycle");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
await test.step("sending inside fullscreen clears the editor and keeps fullscreen open", async () => {
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
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();
|
||||
});
|
||||
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
|
||||
await writeCompactDraft(page, "Typed inside fullscreen");
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
|
||||
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await writeCompactDraft(page, "One more inline message");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-remount-caret-",
|
||||
title: "Remount caret",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await selectComposerDraftRange(page, 5, 5);
|
||||
// The draft owner publishes web edits after the next paint. A rotation inside that
|
||||
// window remounts the editor before the edit has reached the draft store; the paused
|
||||
// clock holds the page there instead of racing the frame.
|
||||
await pausePageClock(page);
|
||||
await composerLocator(page).press("X");
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await page.clock.resume();
|
||||
await expect(composerLocator(page)).toHaveValue("ShortX draft");
|
||||
await expectComposerDraftSelection(page, 6, 6);
|
||||
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 composerLocator(page).press("Y");
|
||||
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
|
||||
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();
|
||||
}
|
||||
@@ -352,7 +253,6 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
|
||||
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
|
||||
"Requested mock prompt rejection",
|
||||
);
|
||||
await expectFullscreenErrorAboveSend(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
@@ -364,76 +264,6 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
|
||||
}
|
||||
});
|
||||
|
||||
async function blurComposer(page: Page): Promise<void> {
|
||||
await composerLocator(page).blur();
|
||||
await expect(composerLocator(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
/** Mid-band at the given height and near the editor's bottom edge. */
|
||||
async function reservedBandPoints(
|
||||
page: Page,
|
||||
bandX: number,
|
||||
topY: number,
|
||||
): Promise<Array<{ x: number; y: number }>> {
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
return [
|
||||
{ x: bandX, y: topY },
|
||||
{ x: bandX, y: editor.y + editor.height - 6 },
|
||||
];
|
||||
}
|
||||
|
||||
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-band-focus-",
|
||||
title: "Composer band focus",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
|
||||
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
const longDraft = "Band first line\nBand second line\nBand third line";
|
||||
await writeCompactDraft(page, longDraft);
|
||||
await expect(fullscreenButton).toBeVisible();
|
||||
const button = (await fullscreenButton.boundingBox())!;
|
||||
const bandX = button.x + button.width / 2;
|
||||
|
||||
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
|
||||
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("the button itself still toggles fullscreen", async () => {
|
||||
await fullscreenButton.click();
|
||||
await expectComposerFullscreenControls(page);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
});
|
||||
|
||||
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
|
||||
await writeCompactDraft(page, "Short");
|
||||
await expect(fullscreenButton).toHaveCount(0);
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue("Short");
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -503,28 +333,15 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
|
||||
const draft = "Live first line\nLive second line\nLive third line";
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
// Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
|
||||
// a desktop-width window still has the tab shortcut.
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await activateRetainedComposerTab(page, first.title);
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
// The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
|
||||
await composerLocator(page).press("Escape");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await writeCompactDraft(page, "Other tab draft");
|
||||
await shrinkVisibleViewport(page);
|
||||
await activateRetainedComposerTab(page, second.title);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
@@ -535,36 +352,6 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
|
||||
}
|
||||
});
|
||||
|
||||
test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-fullscreen-sidebar-",
|
||||
title: "Fullscreen under sidebar",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
const draft = "Line one\nLine two\nLine three";
|
||||
await writeCompactDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderHidden(page);
|
||||
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
|
||||
await swipeOpenMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
// The list covers the pane, so the editor's exit action is not armed.
|
||||
await page.keyboard.press("Escape");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -621,6 +408,30 @@ async function panVisibleViewport(page: Page): Promise<void> {
|
||||
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-",
|
||||
@@ -632,11 +443,11 @@ test("fullscreen Send follows the visible viewport when it shrinks and pans", as
|
||||
await expectComposerVisible(page);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await shrinkVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await panVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@ 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);
|
||||
@@ -129,7 +128,6 @@ 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,
|
||||
@@ -208,40 +206,21 @@ 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) => {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.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);
|
||||
});
|
||||
server.onMessage((frame) => 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 = () => {};
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,7 +2,6 @@ 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,
|
||||
@@ -71,14 +70,6 @@ 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) => {
|
||||
@@ -693,40 +684,6 @@ 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,
|
||||
|
||||
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
@@ -444,25 +444,3 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("downloads a file over direct TCP with its original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const downloaded = await downloadFileFromExplorer(page, fileName);
|
||||
const url = new URL(downloaded.url());
|
||||
expect(url.protocol).toBe("http:");
|
||||
expect(url.port).toBe(getE2EDaemonPort());
|
||||
expect(url.pathname).toBe("/api/files/download");
|
||||
expect(url.searchParams.get("token")).toBeTruthy();
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
expectComposerFullscreenControls,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalLaunchSelected,
|
||||
@@ -156,117 +145,3 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectNewWorkspaceHeaderVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen fills the content area top to bottom and out to the right edge (a pinned
|
||||
* sidebar keeps its column), covers the form, and shows only exit and the mode's submit.
|
||||
*/
|
||||
async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise<void> {
|
||||
const frame = page.getByTestId("composer-fullscreen");
|
||||
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
|
||||
await expect(frame.getByRole("button")).toHaveCount(2);
|
||||
await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible();
|
||||
await expectNewWorkspaceHeaderHidden(page);
|
||||
const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!;
|
||||
await expect
|
||||
.poll(() =>
|
||||
frame.evaluate(
|
||||
(element, point) => {
|
||||
const viewport = window.visualViewport!;
|
||||
const box = element.getBoundingClientRect();
|
||||
const covering = document.elementFromPoint(point.x, point.y);
|
||||
return {
|
||||
fillsHeight:
|
||||
Math.abs(box.top - viewport.offsetTop) < 1 &&
|
||||
Math.abs(box.height - viewport.height) < 1,
|
||||
reachesRightEdge:
|
||||
Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 &&
|
||||
box.left >= viewport.offsetLeft,
|
||||
coversForm: element.contains(covering),
|
||||
};
|
||||
},
|
||||
{ x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 },
|
||||
),
|
||||
)
|
||||
.toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true });
|
||||
}
|
||||
|
||||
test.describe("New workspace: fullscreen composer across the compact breakpoint", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
|
||||
await test.step("chat: fullscreen covers the centered form and exits back into it", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
const draft = "Chat first line\nChat second line\nChat third line";
|
||||
await fillNewWorkspaceDraft(page, draft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectNewWorkspaceHeaderHidden(page);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectNewWorkspaceFullscreen(page, "Create");
|
||||
// The desktop form's own import button is dropped from the fullscreen slot.
|
||||
await expect(
|
||||
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await composerLocator(page).press("End");
|
||||
await composerLocator(page).pressSequentially(" wide");
|
||||
await exitComposerFullscreen(page);
|
||||
await expectNewWorkspaceHeaderVisible(page);
|
||||
await expect(projectPicker).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expectNewWorkspaceDraft(page, `${draft} wide`);
|
||||
});
|
||||
|
||||
// Terminal mode and its prompt are screen state, and the shell remounts the screen
|
||||
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
|
||||
// layout only.
|
||||
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
// The compact launch control opens a sheet, not the desktop menu.
|
||||
await launchTrigger(page).click();
|
||||
const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name });
|
||||
await option.click();
|
||||
await expect(option).not.toBeVisible();
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
const prompt = "Prompt first line\nPrompt second line\nPrompt third line";
|
||||
await fillTerminalPrompt(page, prompt);
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(terminalPromptInput(page)).toBeVisible();
|
||||
await expectNewWorkspaceFullscreen(page, "Launch");
|
||||
await exitComposerFullscreen(page);
|
||||
await expectNewWorkspaceHeaderVisible(page);
|
||||
await expect(projectPicker).toBeVisible();
|
||||
await expect(terminalPromptInput(page)).toHaveValue(prompt);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
expectPairingOffer,
|
||||
expectCompletePairingQr,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
preparePairingHost,
|
||||
@@ -20,20 +18,3 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,29 +10,16 @@ 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>;
|
||||
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
|
||||
patchDaemonConfig(config: {
|
||||
removeProviders?: string[];
|
||||
pluginsEnabled?: boolean;
|
||||
}): Promise<unknown>;
|
||||
installDirectoryPlugin(path: string): Promise<unknown>;
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
getProvidersSnapshot(): Promise<{
|
||||
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
|
||||
}>;
|
||||
@@ -106,45 +93,4 @@ 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,7 +7,6 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -62,10 +61,6 @@ 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,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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,7 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
@@ -1,43 +0,0 @@
|
||||
// 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,21 @@
|
||||
# Android workspace memory diagnostic
|
||||
|
||||
Supplemental reproduction for [#4408](https://github.com/getpaseo/paseo/issues/4408), using the production 0.11.2 APK on an Android 16 emulator. This tests an adapted failure: opening the sidebar with 800 projects, each containing one workspace, produced a `LOW_MEMORY` exit after 66–90 seconds. It does not reproduce the reported 18-second cold-start ANR or establish its cause.
|
||||
|
||||
Before running:
|
||||
|
||||
- Use a dedicated emulator with the production APK installed and notifications permission settled. Bind the agent-device session to that emulator before running.
|
||||
- Pair the app with an isolated daemon containing 800 active workspaces, each in its own project. Use Project grouping and leave projects expanded. The observed fixture used existing directories inside a Git checkout, with workspace titles permuted by `(index * 317) % 800`, no agents, and no terminals.
|
||||
- Record the daemon revision, emulator RAM and Java heap settings, and the project/workspace distribution. Device memory pressure controls whether Android kills the app.
|
||||
|
||||
Run from the repository root with the agent-device session, Android serial, and an artifact directory:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/workspace-memory/android.mjs workspace-memory emulator-5556 .dev/workspace-memory
|
||||
```
|
||||
|
||||
The test cold-starts the paired app, opens its sidebar, samples memory and the process ID every five seconds for two minutes, and verifies that closing the sidebar responds. A process exit or restart fails the assertion. Memory, exit records, and logs stay in the supplied artifact directory.
|
||||
|
||||
Control: repeat with 800 workspaces grouped into one project. That distribution survived the two-minute automated check and responded to closing the sidebar, with total PSS near 300 MB. The project-per-workspace fixture reached about 2.5 GB PSS before Android recorded `LOW_MEMORY`; its Java heap rose and fell rather than showing the reporter's full-heap ANR. Neither result proves that shared collators would fix the report.
|
||||
|
||||
This diagnostic is deliberately outside CI and contains no production change.
|
||||
@@ -0,0 +1,65 @@
|
||||
// Requires a paired production app and the workspace fixture described in README.md.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, serial, artifacts] = process.argv;
|
||||
assert(session && serial && artifacts, "Pass session, Android serial, and artifact directory");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
function adb(...args) {
|
||||
return execFileSync("adb", ["-s", serial, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
function pid() {
|
||||
try {
|
||||
return adb("shell", "pidof", "sh.paseo").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
ad("open", "sh.paseo", "--relaunch", "--platform", "android");
|
||||
ad("wait", "text", "Open menu", "15000");
|
||||
assert.equal(
|
||||
JSON.parse(ad("perf", "memory", "sample", "--json")).data.deviceId,
|
||||
serial,
|
||||
"Session and adb serial must address the same device",
|
||||
);
|
||||
const initialPid = pid();
|
||||
assert(initialPid, "App must have a process before opening the sidebar");
|
||||
ad("press", 'label="Open menu"', "--settle");
|
||||
const startedAt = Date.now();
|
||||
const samples = [];
|
||||
try {
|
||||
while (Date.now() - startedAt < 120_000) {
|
||||
const currentPid = pid();
|
||||
samples.push({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
pid: currentPid,
|
||||
meminfo: currentPid ? adb("shell", "dumpsys", "meminfo", "sh.paseo") : "",
|
||||
});
|
||||
writeFileSync(join(artifacts, "samples.json"), JSON.stringify(samples, null, 2));
|
||||
assert.equal(currentPid, initialPid, "Opening the workspace sidebar must keep the app alive");
|
||||
await delay(5_000);
|
||||
}
|
||||
ad("press", 'label="Close sidebar"', "--settle");
|
||||
ad("wait", "text", "Open menu", "10000");
|
||||
console.log("PASS: app survived two minutes with the sidebar open and responded to Close");
|
||||
} finally {
|
||||
writeFileSync(
|
||||
join(artifacts, "exit-info.txt"),
|
||||
adb("shell", "dumpsys", "activity", "exit-info", "sh.paseo"),
|
||||
);
|
||||
writeFileSync(join(artifacts, "logcat.txt"), adb("logcat", "-d", "-t", "4000"));
|
||||
}
|
||||
@@ -355,12 +355,6 @@ export async function resizeComposerViewport(
|
||||
short: { width: 390, height: 390 },
|
||||
};
|
||||
await page.setViewportSize(sizes[orientation]);
|
||||
// Before the app has loaded there is no layout to wait for.
|
||||
if (page.url() === "about:blank") return;
|
||||
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
|
||||
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
|
||||
// wait for that layout before returning.
|
||||
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
|
||||
}
|
||||
export async function selectComposerDraftRange(
|
||||
page: Page,
|
||||
@@ -396,33 +390,6 @@ export async function expectComposerFullscreenControls(page: Page): Promise<void
|
||||
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
|
||||
await expect(composerInput(page)).toHaveCount(1);
|
||||
}
|
||||
/** The fullscreen frame and its submit button sit inside the browser's visible viewport. */
|
||||
export async function expectComposerFullscreenFitsVisibleViewport(
|
||||
page: Page,
|
||||
submitLabel = "Send message",
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("composer-fullscreen").evaluate((element, label) => {
|
||||
const viewport = window.visualViewport!;
|
||||
const frame = element.getBoundingClientRect();
|
||||
const send = element.querySelector(`[aria-label="${label}"]`)!.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,
|
||||
};
|
||||
}, submitLabel),
|
||||
)
|
||||
.toEqual({ frameFits: true, sendFits: true });
|
||||
}
|
||||
export async function sendComposerWithButton(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
}
|
||||
@@ -438,22 +405,6 @@ export async function configureComposerQueueSend(page: Page): Promise<void> {
|
||||
/** 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.
|
||||
*/
|
||||
/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */
|
||||
export async function switchComposerTabWithKeyboard(
|
||||
page: Page,
|
||||
direction: "previous" | "next",
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(
|
||||
direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight",
|
||||
);
|
||||
}
|
||||
export async function expectWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0);
|
||||
}
|
||||
export async function expectWorkspaceHeaderVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("workspace-tab-switcher-trigger")
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Download, type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
@@ -39,10 +39,3 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
|
||||
await fileExplorerEntry(page, fileName).click({ button: "right" });
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -220,24 +220,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -43,15 +43,6 @@ 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,37 +49,31 @@ export async function seedParentWithSubagent(
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
title: input.parentTitle,
|
||||
},
|
||||
child,
|
||||
child: {
|
||||
id: child.id,
|
||||
title: input.childTitle,
|
||||
},
|
||||
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 },
|
||||
@@ -147,12 +141,6 @@ 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,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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 header starts below the sheet's drag indicator, inside the handle. */
|
||||
const SHEET_INDICATOR_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);
|
||||
const header = sheet.getByTestId("workspace-sheet-header");
|
||||
const body = sheet.getByTestId("workspace-sheet-body");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
|
||||
if (!headerBox || !bodyBox) return null;
|
||||
const top = Math.round(headerBox.y);
|
||||
return {
|
||||
dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
|
||||
topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_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);
|
||||
}
|
||||
|
||||
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
||||
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.last()
|
||||
.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");
|
||||
}
|
||||
@@ -29,15 +29,4 @@ 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,9 +34,10 @@ 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 (compactLabel === "xhigh" || compactLabel === "extrahigh") {
|
||||
if (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ 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 {
|
||||
@@ -550,7 +549,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
<WorkspaceLayoutModePublisher />
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
@@ -665,7 +663,7 @@ function MobileGestureWrapper({
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||
{children}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import {
|
||||
ComposerEditingScope,
|
||||
WorkspaceComposerEditingSessionsProvider,
|
||||
} from "@/composer/editing-session";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys";
|
||||
|
||||
/** New workspace drafts share one editing-session home, keyed like a tab by draft. */
|
||||
const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace";
|
||||
|
||||
export default function NewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -32,28 +23,16 @@ export default function NewWorkspaceRoute() {
|
||||
draftId ?? null,
|
||||
]);
|
||||
|
||||
// The shell remounts this route when the viewport crosses the compact breakpoint;
|
||||
// the draft's editing session (fullscreen, retained caret) lives above the shell.
|
||||
const draftKey = buildNewWorkspaceDraftKey(draftId);
|
||||
const sessionTabIds = useMemo(() => [draftKey], [draftKey]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<WorkspaceComposerEditingSessionsProvider
|
||||
workspaceKey={NEW_WORKSPACE_SESSIONS_KEY}
|
||||
tabIds={sessionTabIds}
|
||||
>
|
||||
<ComposerEditingScope tabId={draftKey} targetKey={draftKey}>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</ComposerEditingScope>
|
||||
</WorkspaceComposerEditingSessionsProvider>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
@@ -223,8 +223,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
|
||||
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
|
||||
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const combinedStyle = useMemo(
|
||||
() => [
|
||||
@@ -240,39 +239,6 @@ export function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return <Animated.View pointerEvents="none" style={combinedStyle} />;
|
||||
}
|
||||
|
||||
const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
|
||||
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
}));
|
||||
|
||||
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
|
||||
|
||||
/**
|
||||
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
|
||||
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
|
||||
* sheet whose content scrolls on its own keeps its header draggable without content panning.
|
||||
*/
|
||||
export function SheetHandle({
|
||||
children,
|
||||
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
|
||||
indicatorStyle: _indicatorStyle,
|
||||
...props
|
||||
}: ComponentProps<typeof BottomSheetHandle>) {
|
||||
if (!children) return <ThemedBottomSheetHandle {...props} />;
|
||||
// The children carry their own semantics; the handle's slider role would swallow them on web.
|
||||
return (
|
||||
<ThemedBottomSheetHandle
|
||||
{...props}
|
||||
style={HANDLE_WITH_CONTENT_STYLE}
|
||||
accessible={false}
|
||||
accessibilityRole={null}
|
||||
accessibilityLabel={null}
|
||||
accessibilityHint={null}
|
||||
>
|
||||
{children}
|
||||
</ThemedBottomSheetHandle>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet body, indented to the sheet's content inset.
|
||||
*
|
||||
@@ -483,6 +449,7 @@ export function AdaptiveModalSheet({
|
||||
bodyStyle,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -514,6 +481,10 @@ export function AdaptiveModalSheet({
|
||||
) : null,
|
||||
[footer, footerClearanceStyle, footerContainerStyle],
|
||||
);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
// A newly opened sheet owns input. A keyboard belonging to the sheet below
|
||||
@@ -629,7 +600,7 @@ export function AdaptiveModalSheet({
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleComponent={SheetHandle}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
|
||||
@@ -35,7 +35,6 @@ import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -806,18 +805,21 @@ function DesktopSidebar({
|
||||
|
||||
function WorkspacesSectionHeader() {
|
||||
return (
|
||||
<SidebarSectionHeader title="Workspaces">
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarDisplayPreferencesMenu />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<IconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</SidebarSectionHeader>
|
||||
<View style={styles.workspacesSectionHeader}>
|
||||
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
||||
<View style={styles.workspacesSectionActions}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarDisplayPreferencesMenu />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<IconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -848,6 +850,29 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderGroupBelowChrome: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
workspacesSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
|
||||
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
|
||||
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 4,
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
workspacesSectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
workspacesSectionActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -3,71 +3,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
|
||||
|
||||
describe("splitHtmlishMarkdown", () => {
|
||||
it("renders heading text beside a linked picture without literal heading tags", () => {
|
||||
const source =
|
||||
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Retrigger",
|
||||
src: "https://example.com/l.svg",
|
||||
href: "https://example.com/r",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Confidence Score: 5/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
|
||||
"keeps a mixed %s heading together after preceding markdown",
|
||||
(heading) => {
|
||||
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "Intro\n\n" },
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: "Caption\n\nOutro" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves formatted text before and after images in a details heading", () => {
|
||||
const source =
|
||||
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "More",
|
||||
body: "**Before***After*",
|
||||
bodyParts: [
|
||||
{ kind: "markdown", text: "**Before**" },
|
||||
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
|
||||
{ kind: "markdown", text: "*After*" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps mixed HTML headings literal in code examples", () => {
|
||||
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
|
||||
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("preserves unknown wrappers around text-only headings", () => {
|
||||
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
|
||||
{ kind: "markdown", text: "<section>Heading</section>" },
|
||||
]);
|
||||
});
|
||||
|
||||
const inlineImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
|
||||
"",
|
||||
|
||||
@@ -125,20 +125,9 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isHeadingTag(token)) {
|
||||
const closeIndex = findMixedHeadingClose(tokens, cursor);
|
||||
if (closeIndex !== null) {
|
||||
// Headings render as their contents. Split those contents together so an
|
||||
// embedded image cannot strand the opening heading tag in a text slice.
|
||||
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
|
||||
appendDisplayPart(parts, part);
|
||||
}
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
@@ -270,44 +259,15 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (
|
||||
isOpenTag(tokens[index], "details") ||
|
||||
findMixedHeadingClose(tokens, index) !== null ||
|
||||
parseInlineImageAt(tokens, index)
|
||||
) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
|
||||
const token = tokens[start];
|
||||
if (!isHeadingTag(token)) {
|
||||
return null;
|
||||
}
|
||||
const closeIndex = findMatchingClose(tokens, start, token.name);
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
for (let index = start + 1; index < closeIndex; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return closeIndex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
|
||||
if (part.kind === "markdown") {
|
||||
appendMarkdownPart(parts, part.text);
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
|
||||
@@ -95,11 +95,11 @@ function CustomModelRow({
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,7 +762,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -808,10 +807,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -147,29 +147,4 @@ 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,7 +1,6 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -47,7 +46,6 @@ 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,7 +10,6 @@ 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,
|
||||
@@ -30,9 +29,6 @@ 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,
|
||||
@@ -137,20 +133,6 @@ 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>
|
||||
@@ -722,10 +704,6 @@ 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,7 +30,7 @@ import {
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
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";
|
||||
@@ -93,7 +93,7 @@ import {
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
@@ -2114,7 +2114,6 @@ function ProjectModeList({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
@@ -2414,12 +2413,7 @@ function ProjectModeList({
|
||||
<>
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<SidebarSectionHeader
|
||||
title={t("sidebar.pinned.title")}
|
||||
collapsed={pinnedCollapsed}
|
||||
onToggle={togglePinnedCollapsed}
|
||||
testID="sidebar-pinned-section-header"
|
||||
/>
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<DraggableList
|
||||
@@ -2556,12 +2550,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
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,
|
||||
@@ -2609,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,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { density, densityFontSize } from "@/styles/density";
|
||||
|
||||
export function PinnedSectionHeader({
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onToggle}
|
||||
style={styles.header}
|
||||
testID="sidebar-pinned-section-header"
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
|
||||
{hovered || isNative || isCompact ? (
|
||||
<DensityIcon
|
||||
icon={collapsed ? ChevronRight : ChevronDown}
|
||||
size="xs"
|
||||
color="foregroundMuted"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: density.rowHeight,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "sm"),
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
@@ -4,7 +4,7 @@ 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 { densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
@@ -24,9 +24,6 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
|
||||
// Every surfaced status shares one badge shell, so the badge never changes size or position
|
||||
// between states. Only the thing inside it changes.
|
||||
// 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 STATUS_BADGE_SIZE = 12;
|
||||
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
|
||||
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
|
||||
@@ -276,10 +273,10 @@ const styles = StyleSheet.create((theme) => {
|
||||
// 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 slot
|
||||
// only as tall as the icon puts it above the title and the kebab — which is why the
|
||||
// workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
|
||||
// workspace status indicator shares the same `rowLineHeight`.
|
||||
projectLeadingVisualSlot: {
|
||||
width: densityIconBox("md"),
|
||||
height: LEADING_SLOT_HEIGHT,
|
||||
height: density.rowLineHeight,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
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
|
||||
@@ -100,13 +100,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
|
||||
@@ -152,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { density } from "@/styles/density";
|
||||
|
||||
/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle`
|
||||
* collapses from its title; children sit at the trailing edge as its actions. */
|
||||
export function SidebarSectionHeader({
|
||||
title,
|
||||
collapsed,
|
||||
onToggle,
|
||||
testID,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
testID?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
{onToggle ? (
|
||||
<CollapsibleTitle
|
||||
title={title}
|
||||
collapsed={collapsed ?? false}
|
||||
onToggle={onToggle}
|
||||
testID={testID}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.title} testID={testID}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleTitle({
|
||||
title,
|
||||
collapsed,
|
||||
onToggle,
|
||||
testID,
|
||||
}: {
|
||||
title: string;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
testID?: string;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onToggle}
|
||||
style={styles.toggle}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{hovered || isNative || isCompact ? (
|
||||
<DensityIcon
|
||||
icon={collapsed ? ChevronRight : ChevronDown}
|
||||
size="xs"
|
||||
color="foregroundMuted"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: density.rowHeight,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// The title lands at spacing[2] to align with the rows' leading icons. Trailing actions
|
||||
// are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink
|
||||
// rather than the box to the row rail.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 4,
|
||||
userSelect: "none",
|
||||
},
|
||||
// The toggle reaches back over the header's leading padding so the whole label edge is pressable.
|
||||
toggle: {
|
||||
alignSelf: "stretch",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginLeft: -theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
@@ -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 { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||
@@ -146,7 +146,6 @@ export function SidebarStatusWorkspaceList({
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
);
|
||||
@@ -203,12 +202,7 @@ export function SidebarStatusWorkspaceList({
|
||||
<>
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<SidebarSectionHeader
|
||||
title={t("sidebar.pinned.title")}
|
||||
collapsed={pinnedCollapsed}
|
||||
onToggle={togglePinnedCollapsed}
|
||||
testID="sidebar-pinned-section-header"
|
||||
/>
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<DraggableList
|
||||
@@ -1105,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,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { densityIconBox } from "@/styles/density";
|
||||
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";
|
||||
@@ -453,9 +453,6 @@ export function SidebarWorkspaceTrailingActionOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
// The status dot centres on the title's first line, so both share its line height.
|
||||
const TITLE_LINE_HEIGHT = 20;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowContent: {
|
||||
position: "relative",
|
||||
@@ -484,7 +481,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: densityIconBox("md"),
|
||||
height: TITLE_LINE_HEIGHT,
|
||||
height: density.rowLineHeight,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
@@ -516,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: TITLE_LINE_HEIGHT,
|
||||
lineHeight: density.rowLineHeight,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
|
||||
@@ -154,4 +154,3 @@ export function useIsolatedBottomSheetVisibility({
|
||||
}
|
||||
|
||||
export { SheetVisibleFrame } from "./visible-frame";
|
||||
export { systemBackPress } from "./back-press";
|
||||
|
||||
@@ -104,13 +104,6 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
});
|
||||
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
},
|
||||
setSelection: (selection) => {
|
||||
if (inputRef.current?.setSelection) {
|
||||
inputRef.current.setSelection(selection.start, selection.end);
|
||||
return;
|
||||
}
|
||||
inputRef.current?.setNativeProps?.({ selection });
|
||||
},
|
||||
reset: () => {
|
||||
textRef.current = "";
|
||||
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
||||
|
||||
@@ -77,10 +77,6 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
input.setSelectionRange(selection.start, selection.end);
|
||||
}
|
||||
},
|
||||
setSelection: (selection) => {
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
input?.setSelectionRange?.(selection.start, selection.end);
|
||||
},
|
||||
reset: () => {
|
||||
textRef.current = "";
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface EditingTextInputHandle {
|
||||
isFocused(): boolean;
|
||||
getText(): string;
|
||||
replaceText(text: string, selection?: { start: number; end: number }): void;
|
||||
setSelection(selection: { start: number; end: number }): void;
|
||||
/** Clear the editor and reset its intrinsic layout, preserving focus intent. */
|
||||
reset(): void;
|
||||
getNativeRef(): unknown;
|
||||
|
||||
@@ -125,7 +125,8 @@ function buildCreateAgentOptions({
|
||||
provider,
|
||||
}: {
|
||||
composerState: {
|
||||
effectiveModeId: string;
|
||||
modeOptions: { id: string }[];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
};
|
||||
@@ -136,11 +137,20 @@ function buildCreateAgentOptions({
|
||||
workspaceId: string;
|
||||
provider: CreateAgentRequestOptions["provider"];
|
||||
}): CreateAgentRequestOptions {
|
||||
// Reconcile the selected mode against the discovered modes. The mode picker
|
||||
// shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale
|
||||
// globally-remembered mode this workspace's provider config no longer
|
||||
// defines), so the submitted mode must match that display rather than send a
|
||||
// stale mode the provider would reject.
|
||||
const modeOptionIds = composerState.modeOptions.map((mode) => mode.id);
|
||||
const reconciledMode = modeOptionIds.includes(composerState.selectedMode)
|
||||
? composerState.selectedMode
|
||||
: (modeOptionIds[0] ?? "");
|
||||
return {
|
||||
provider,
|
||||
cwd: workspaceDirectory,
|
||||
workspaceId,
|
||||
...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}),
|
||||
...(reconciledMode !== "" ? { modeId: reconciledMode } : {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ComposerDockBackground } from "./internal/background";
|
||||
export { ComposerDockBackground } from "./internal/background";
|
||||
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -9,22 +8,22 @@ import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||
import { StyleSheet as ThemedStyleSheet } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
useDerivedValue,
|
||||
useAnimatedReaction,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerSlotCapacity,
|
||||
resolveInlineComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
@@ -56,6 +55,7 @@ function ComposerViewport({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const capacity = useDerivedValue(() => sizing.value?.capacity);
|
||||
const measureViewport = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
measuredHeight.value = event.nativeEvent.layout.height;
|
||||
@@ -66,24 +66,20 @@ function ComposerViewport({
|
||||
|
||||
return (
|
||||
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
|
||||
<ViewportSizing.Provider value={sizing}>{children}</ViewportSizing.Provider>
|
||||
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bound the complete composer (including its controls), not just the text input. */
|
||||
function ComposerViewportContent({
|
||||
style,
|
||||
fullscreen = false,
|
||||
...props
|
||||
}: ViewProps & { fullscreen?: boolean }) {
|
||||
function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const sizing = useContext(ViewportSizing);
|
||||
if (!sizing) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||
const capacity = useContext(ViewportCapacity);
|
||||
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||
const constraint = useAnimatedStyle(() => ({
|
||||
// Only viewport changes and keyboard start/end events trigger layout. Motion
|
||||
// stays in KeyboardTranslateView and never changes this constraint per frame.
|
||||
maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }),
|
||||
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -112,71 +108,37 @@ function useCenteredClearance(safeAreaBottom: number) {
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
/**
|
||||
* Fullscreen is a layout mode of the same mounted composer: the slot fills the
|
||||
* stationary viewport over the content and lays out the keyboard itself, so the
|
||||
* surface stops translating. The screen that owns the chrome hides it.
|
||||
*/
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
/** The stationary viewport, translated surface, and bounded composer are one owner. */
|
||||
export function ComposerDock({
|
||||
children: [content, composer, overlay],
|
||||
centered = false,
|
||||
fullscreen = false,
|
||||
}: ComposerDockProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
const fullscreenSurface = [
|
||||
themedStyles.fullscreenComposer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
},
|
||||
];
|
||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
if (centered) {
|
||||
// Same tree in both modes. Fullscreen stops centering and translating, hides the
|
||||
// setup fields, and lets the composer slot take the whole stationary viewport.
|
||||
return (
|
||||
<ComposerViewport
|
||||
style={
|
||||
fullscreen
|
||||
? dockStyles.viewport
|
||||
: [dockStyles.centeredViewport, { paddingBottom: bottomInset }]
|
||||
}
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView
|
||||
style={
|
||||
fullscreen ? dockStyles.surface : [dockStyles.centered, { maxWidth: contentMaxWidth }]
|
||||
}
|
||||
enabled={!fullscreen}
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.composer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<ScrollView
|
||||
style={fullscreen ? dockStyles.hiddenSetup : dockStyles.setup}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
</ScrollView>
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
<ComposerViewportContent style={dockStyles.centeredComposer}>
|
||||
{composer}
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
@@ -186,7 +148,7 @@ export function ComposerDock({
|
||||
}
|
||||
return (
|
||||
<ComposerViewport style={dockStyles.viewport}>
|
||||
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
|
||||
<KeyboardTranslateView style={dockStyles.surface}>
|
||||
<View
|
||||
testID="composer-dock-content"
|
||||
collapsable={false}
|
||||
@@ -200,19 +162,8 @@ export function ComposerDock({
|
||||
{content}
|
||||
</View>
|
||||
</View>
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.fullscreenSlot : dockStyles.composer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
fullscreen
|
||||
? fullscreenSurface
|
||||
: [dockStyles.composer, { paddingBottom: insets.bottom }]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
</View>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
@@ -227,16 +178,9 @@ const dockStyles = StyleSheet.create({
|
||||
surface: { flex: 1 },
|
||||
content: { flex: 1, justifyContent: "flex-end" },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
fullscreenSlot: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
|
||||
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
centered: { flexShrink: 1, width: "100%" },
|
||||
// Reserve the composer's own capped height before the setup scroll view shrinks.
|
||||
centeredComposer: { width: "100%", flexShrink: 0 },
|
||||
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
hiddenSetup: { display: "none" },
|
||||
});
|
||||
|
||||
// The fullscreen composer paints its own surface, including under the safe areas.
|
||||
const themedStyles = ThemedStyleSheet.create((theme) => ({
|
||||
fullscreenComposer: { flex: 1, backgroundColor: theme.colors.surface1 },
|
||||
}));
|
||||
|
||||
@@ -1,69 +1,48 @@
|
||||
export { ComposerDockBackground } from "./internal/background";
|
||||
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useState, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { resolveInlineComposerCapacity } from "./internal/capacity";
|
||||
import { useFullscreenSlotFrame } from "./internal/fullscreen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
/** See the native dock: the same mounted composer fills the visible surface. */
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
export function ComposerDock({
|
||||
children: [content, composer, overlay],
|
||||
centered = false,
|
||||
fullscreen = false,
|
||||
}: ComposerDockProps) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>();
|
||||
const surfaceRef = useRef<View>(null);
|
||||
const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize);
|
||||
const [availableHeight, setAvailableHeight] = useState<number>();
|
||||
const constraint = useMemo(
|
||||
() => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }),
|
||||
[surfaceSize?.height, compact],
|
||||
() => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }),
|
||||
[availableHeight, compact],
|
||||
);
|
||||
const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
if (height > 0) setSurfaceSize({ width, height });
|
||||
const { height } = event.nativeEvent.layout;
|
||||
if (height > 0) setAvailableHeight(height);
|
||||
}, []);
|
||||
if (centered) {
|
||||
return (
|
||||
<View ref={surfaceRef} style={styles.centered} onLayout={measureViewport}>
|
||||
<View style={styles.centered} onLayout={measureViewport}>
|
||||
<View style={styles.form}>
|
||||
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
</ScrollView>
|
||||
<View
|
||||
style={
|
||||
fullscreen
|
||||
? [styles.fullscreenComposer, fullscreenFrame]
|
||||
: [styles.centeredComposer, constraint]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
</View>
|
||||
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
|
||||
{overlay}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View ref={surfaceRef} style={styles.surface} onLayout={measureViewport}>
|
||||
<View style={styles.surface} onLayout={measureViewport}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
<View
|
||||
style={
|
||||
fullscreen ? [styles.fullscreenComposer, fullscreenFrame] : [styles.composer, constraint]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
</View>
|
||||
<View style={[styles.composer, constraint]}>{composer}</View>
|
||||
{overlay}
|
||||
</View>
|
||||
);
|
||||
@@ -73,10 +52,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
surface: { flex: 1, overflow: "hidden" },
|
||||
content: { flex: 1, justifyContent: "flex-end" },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
// Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor
|
||||
// never reaches the zoomed visual viewport, while a fixed box chains to the viewport
|
||||
// directly. React Native Web renders `fixed`; React Native's style type omits it.
|
||||
fullscreenComposer: { position: "fixed" as "absolute" },
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerSlotCapacity,
|
||||
resolveInlineComposerCapacity,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
@@ -20,20 +19,6 @@ describe("composer viewport", () => {
|
||||
expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245);
|
||||
expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined();
|
||||
});
|
||||
it("lets a fullscreen composer fill the viewport while inline keeps its cap", () => {
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
safeAreaBottom: 0,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: true })).toBe(582);
|
||||
expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: false })).toBe(147);
|
||||
expect(resolveComposerSlotCapacity(undefined, { compact: true, fullscreen: true })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
|
||||
@@ -10,8 +10,6 @@ interface ComposerGeometry {
|
||||
export interface ComposerCapacity {
|
||||
keyboardReserve: number;
|
||||
capacity: number;
|
||||
/** The stationary viewport; a fullscreen composer fills it and lays out the keyboard itself. */
|
||||
viewportHeight: number;
|
||||
}
|
||||
|
||||
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
@@ -50,7 +48,6 @@ export function updateComposerCapacity(
|
||||
return {
|
||||
keyboardReserve,
|
||||
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
|
||||
viewportHeight: input.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,19 +60,3 @@ export function resolveInlineComposerCapacity(
|
||||
if (capacity === undefined) return undefined;
|
||||
return compact ? capacity * 0.6 : capacity;
|
||||
}
|
||||
|
||||
export interface ComposerSlotLayout {
|
||||
compact: boolean;
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
/** The bound on the composer slot: inline capacity, or the whole viewport in fullscreen. */
|
||||
export function resolveComposerSlotCapacity(
|
||||
sizing: ComposerCapacity | undefined,
|
||||
layout: ComposerSlotLayout,
|
||||
): number | undefined {
|
||||
"worklet";
|
||||
if (sizing === undefined) return undefined;
|
||||
if (layout.fullscreen) return sizing.viewportHeight;
|
||||
return resolveInlineComposerCapacity(sizing.capacity, layout.compact);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet, type ViewProps } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { KeyboardTranslateView, useKeyboardShift } from "@/keyboard/shift";
|
||||
|
||||
/**
|
||||
* The editing region of a composer. Inline it only continues the shrink chain. In
|
||||
* fullscreen it fills the stationary frame and reserves the keyboard's space at
|
||||
* motion boundaries, the same boundary-time layout the dock capacity uses: the
|
||||
* keyboard start/end events write `layoutShift`; frames in between move the footer.
|
||||
*/
|
||||
export function ComposerFullscreenBody({
|
||||
fullscreen,
|
||||
children,
|
||||
}: {
|
||||
fullscreen: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
const reservation = useAnimatedStyle(() => ({
|
||||
marginBottom: fullscreen ? layoutShift.value : 0,
|
||||
}));
|
||||
return (
|
||||
<Animated.View style={[fullscreen ? styles.fullscreen : styles.inline, reservation]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Send row of a fullscreen composer rides the keyboard like the inline dock surface. */
|
||||
export function ComposerFullscreenFooter({ children, ...props }: ViewProps) {
|
||||
return <KeyboardTranslateView {...props}>{children}</KeyboardTranslateView>;
|
||||
}
|
||||
|
||||
// Plain React Native styles: these land on Reanimated views (docs/unistyles.md).
|
||||
const styles = StyleSheet.create({
|
||||
inline: { flexShrink: 1 },
|
||||
fullscreen: { flex: 1, minHeight: 0 },
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
|
||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||
|
||||
/** The editing region of a composer; see the native file for the keyboard contract. */
|
||||
export function ComposerFullscreenBody({
|
||||
fullscreen,
|
||||
children,
|
||||
}: {
|
||||
fullscreen: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <View style={fullscreen ? styles.fullscreen : styles.inline}>{children}</View>;
|
||||
}
|
||||
|
||||
export function ComposerFullscreenFooter({ children, ...props }: ViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
}
|
||||
|
||||
export interface ComposerSlotFrame {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a fullscreen composer sits, in layout-viewport coordinates for a fixed box: the
|
||||
* part of the dock surface the browser's visible viewport shows. A mobile browser
|
||||
* keyboard shrinks or pans that viewport without resizing the page, so the frame
|
||||
* follows it instead of the layout.
|
||||
*/
|
||||
export function useFullscreenSlotFrame(
|
||||
active: boolean,
|
||||
surfaceRef: RefObject<View | null>,
|
||||
surfaceSize: { width: number; height: number } | undefined,
|
||||
): ComposerSlotFrame | undefined {
|
||||
const [frame, setFrame] = useState<ComposerSlotFrame>();
|
||||
useLayoutEffect(() => {
|
||||
if (!active) {
|
||||
setFrame(undefined);
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const surface = surfaceRef.current as unknown as HTMLElement | null;
|
||||
if (!surface) return;
|
||||
setFrame(resolveVisibleSurfaceFrame(surface.getBoundingClientRect(), readVisibleViewport()));
|
||||
};
|
||||
const visualViewport = window.visualViewport;
|
||||
update();
|
||||
visualViewport?.addEventListener("resize", update);
|
||||
visualViewport?.addEventListener("scroll", update);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
visualViewport?.removeEventListener("resize", update);
|
||||
visualViewport?.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [active, surfaceRef, surfaceSize]);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function readVisibleViewport() {
|
||||
const viewport = window.visualViewport;
|
||||
return {
|
||||
top: viewport?.offsetTop ?? 0,
|
||||
left: viewport?.offsetLeft ?? 0,
|
||||
width: viewport?.width ?? window.innerWidth,
|
||||
height: viewport?.height ?? window.innerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVisibleSurfaceFrame(
|
||||
surface: { top: number; left: number; right: number; bottom: number },
|
||||
viewport: { top: number; left: number; width: number; height: number },
|
||||
): ComposerSlotFrame {
|
||||
const top = Math.max(viewport.top, surface.top);
|
||||
const left = Math.max(viewport.left, surface.left);
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width: Math.max(0, Math.min(viewport.left + viewport.width, surface.right) - left),
|
||||
height: Math.max(0, Math.min(viewport.top + viewport.height, surface.bottom) - top),
|
||||
};
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inline: { flexShrink: 1 },
|
||||
fullscreen: { flex: 1, minHeight: 0 },
|
||||
});
|
||||
@@ -210,8 +210,6 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
const hydratedTextReplacement = getLatest().textReplacement;
|
||||
// The editor applies a retained caret to the hydrated text, so hydration announces itself.
|
||||
expect(hydratedTextReplacement.kind).toBe("hydrated");
|
||||
|
||||
await act(async () => {
|
||||
getLatest().editText("hello world");
|
||||
@@ -225,7 +223,6 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
|
||||
expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement);
|
||||
expect(getLatest().textReplacement.kind).toBe("replace");
|
||||
expect(getLatest().textReplacement.text).toBe("replacement text");
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -47,7 +46,6 @@ interface UseAgentInputDraftInput {
|
||||
|
||||
type DraftComposerState = UseAgentFormStateResult & {
|
||||
workingDir: string;
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -123,10 +121,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}));
|
||||
|
||||
const publishTextReplacement = useCallback(
|
||||
(kind: "hydrated" | "replace", nextText: string) => {
|
||||
(nextText: string) => {
|
||||
textReplacementRevisionRef.current += 1;
|
||||
setTextReplacement({
|
||||
kind,
|
||||
kind: "replace",
|
||||
key: `${draftKey}:${textReplacementRevisionRef.current}`,
|
||||
text: nextText,
|
||||
});
|
||||
@@ -176,7 +174,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
(nextText: string) => {
|
||||
textPublication.cancel();
|
||||
useDraftStore.getState().editDraftText({ draftKey, text: nextText });
|
||||
publishTextReplacement("replace", nextText);
|
||||
publishTextReplacement(nextText);
|
||||
},
|
||||
[draftKey, publishTextReplacement, textPublication],
|
||||
);
|
||||
@@ -229,7 +227,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
|
||||
publishTextReplacement("hydrated", hydratedText);
|
||||
publishTextReplacement(hydratedText);
|
||||
setHydratedDraftKey(draftKey);
|
||||
}
|
||||
})();
|
||||
@@ -258,11 +256,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveModeId = useMemo(
|
||||
() => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading),
|
||||
[formState.isModelLoading, providerSelection],
|
||||
);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() => resolveEffectiveComposerModelId(providerSelection),
|
||||
[providerSelection],
|
||||
@@ -322,7 +315,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return {
|
||||
...formState,
|
||||
workingDir,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
@@ -337,7 +329,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}, [
|
||||
commandDraft,
|
||||
composerOptions,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatures,
|
||||
@@ -364,7 +355,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandTarget,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ComposerDock } from "@/composer/dock";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
|
||||
@@ -87,30 +86,46 @@ function resolveAutoSubmitConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Reconcile the form's selected mode against the currently discovered modes.
|
||||
// The mode picker displays modeOptions[0] when the stored mode isn't in the
|
||||
// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config
|
||||
// no longer defines), so the submitted mode must match that display — otherwise
|
||||
// we'd send a stale mode the provider rejects while the UI showed a valid one.
|
||||
function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string {
|
||||
if (modeOptionIds.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? "");
|
||||
}
|
||||
|
||||
function resolveDraftModeIdOverride(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
effectiveModeId: string;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
}): { modeId: string } | Record<string, never> {
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
if (autoSubmitConfig?.modeId) {
|
||||
return { modeId: autoSubmitConfig.modeId };
|
||||
}
|
||||
if (effectiveModeId !== "") {
|
||||
return { modeId: effectiveModeId };
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return { modeId: reconciled };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveDraftModeId(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
effectiveModeId: string;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
}): string | null {
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
if (autoSubmitConfig?.modeId !== undefined) {
|
||||
return autoSubmitConfig.modeId;
|
||||
}
|
||||
if (effectiveModeId !== "") {
|
||||
return effectiveModeId;
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return reconciled;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -128,7 +143,8 @@ async function submitDraftCreateRequest(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
composerState: {
|
||||
selectedProvider: string | null;
|
||||
effectiveModeId: string;
|
||||
selectedMode: string;
|
||||
modeOptions: readonly { id: string }[];
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -161,7 +177,8 @@ async function submitDraftCreateRequest(input: {
|
||||
}
|
||||
const modeIdOverride = resolveDraftModeIdOverride({
|
||||
autoSubmitConfig,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
});
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider,
|
||||
@@ -202,7 +219,8 @@ function buildDraftAgentSnapshot(input: {
|
||||
composerState: {
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
effectiveModeId: string;
|
||||
modeOptions: readonly { id: string }[];
|
||||
selectedMode: string;
|
||||
selectedProvider: string | null;
|
||||
agentControls: { features?: Agent["features"] };
|
||||
};
|
||||
@@ -216,7 +234,8 @@ function buildDraftAgentSnapshot(input: {
|
||||
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
|
||||
const modeId = resolveDraftModeId({
|
||||
autoSubmitConfig,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
});
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
@@ -386,8 +405,6 @@ export function WorkspaceDraftAgentTab({
|
||||
}, [pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const editingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
@@ -631,10 +648,10 @@ export function WorkspaceDraftAgentTab({
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ComposerDock fullscreen={isComposerFullscreen}>
|
||||
<ComposerDock>
|
||||
{dockContent}
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
@@ -658,7 +675,6 @@ export function WorkspaceDraftAgentTab({
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
@@ -676,8 +692,6 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
flexShrink: 1,
|
||||
// Content-sized inline; fills the dock's fullscreen slot.
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,61 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
|
||||
import { ComposerEditingSession } from "./index";
|
||||
|
||||
describe("composer editing session", () => {
|
||||
it("publishes fullscreen changes once per transition", () => {
|
||||
function leaveLiveDraft(session: ComposerEditingSession) {
|
||||
session.captureForRemount({
|
||||
snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } },
|
||||
focused: true,
|
||||
replacementKey: "draft:applied",
|
||||
});
|
||||
}
|
||||
|
||||
describe("composer editing remount handoff", () => {
|
||||
it("keeps live text and selection when remounting from an initial draft publication", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
const changes: boolean[] = [];
|
||||
session.subscribe(() => changes.push(session.getFullscreen()));
|
||||
session.setFullscreen(true);
|
||||
session.setFullscreen(true);
|
||||
session.setFullscreen(false);
|
||||
expect(changes).toEqual([true, false]);
|
||||
leaveLiveDraft(session);
|
||||
const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" });
|
||||
expect(handoff?.snapshot).toEqual({
|
||||
text: "Unpublished live draft",
|
||||
selection: { start: 4, end: 17 },
|
||||
});
|
||||
expect(handoff?.focused).toBe(true);
|
||||
});
|
||||
|
||||
it("hands the retained caret and focus intent to exactly one later mount", () => {
|
||||
it("keeps local edits when the last applied replacement is still current", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
expect(session.takeEditor()).toBeNull();
|
||||
session.retainEditor({ selection: { start: 4, end: 17 }, focused: true });
|
||||
expect(session.takeEditor()).toEqual({ selection: { start: 4, end: 17 }, focused: true });
|
||||
expect(session.takeEditor()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer editing sessions", () => {
|
||||
const workspace = "server:workspace";
|
||||
const target = JSON.stringify({ kind: "agent", agentId: "a" });
|
||||
|
||||
it("keeps one session per tab and purpose until the tab target changes", () => {
|
||||
const sessions = new ComposerEditingSessions();
|
||||
const chat = sessions.get(workspace, "tab-1", target, "chat");
|
||||
expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(chat);
|
||||
expect(sessions.get(workspace, "tab-1", target, "terminal")).not.toBe(chat);
|
||||
const retargeted = JSON.stringify({ kind: "agent", agentId: "b" });
|
||||
expect(sessions.get(workspace, "tab-1", retargeted, "chat")).not.toBe(chat);
|
||||
});
|
||||
|
||||
it("reports a tab as fullscreen while any of its composers is fullscreen", () => {
|
||||
const sessions = new ComposerEditingSessions();
|
||||
const notifications: boolean[] = [];
|
||||
sessions.subscribe(() => notifications.push(sessions.isTabFullscreen(workspace, "tab-1")));
|
||||
const chat = sessions.get(workspace, "tab-1", target, "chat");
|
||||
sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true);
|
||||
expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(false);
|
||||
expect(sessions.isTabFullscreen(workspace, null)).toBe(false);
|
||||
chat.setFullscreen(true);
|
||||
expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(true);
|
||||
chat.setFullscreen(false);
|
||||
expect(notifications).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it("drops sessions for closed tabs and keeps retained ones", () => {
|
||||
const sessions = new ComposerEditingSessions();
|
||||
const kept = sessions.get(workspace, "tab-1", target, "chat");
|
||||
kept.setFullscreen(true);
|
||||
sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true);
|
||||
sessions.retain(workspace, ["tab-1"]);
|
||||
expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(kept);
|
||||
expect(sessions.isTabFullscreen(workspace, "tab-2")).toBe(false);
|
||||
expect(sessions.get(workspace, "tab-2", target, "chat").getFullscreen()).toBe(false);
|
||||
leaveLiveDraft(session);
|
||||
const handoff = session.takeHandoff({
|
||||
kind: "replace",
|
||||
key: "draft:applied",
|
||||
text: "old replacement",
|
||||
});
|
||||
expect(handoff?.snapshot).toEqual({
|
||||
text: "Unpublished live draft",
|
||||
selection: { start: 4, end: 17 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["New explicit draft", ""])(
|
||||
"honors an explicit replacement made while the editor is absent: %j",
|
||||
(text) => {
|
||||
const session = new ComposerEditingSession();
|
||||
leaveLiveDraft(session);
|
||||
const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text });
|
||||
expect(handoff?.snapshot).toEqual({
|
||||
text,
|
||||
selection: { start: text.length, end: text.length },
|
||||
});
|
||||
expect(handoff?.focused).toBe(true);
|
||||
expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -8,72 +7,62 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { ComposerInputMode } from "@/composer/input-mode";
|
||||
|
||||
export interface ComposerSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** What an unmounting editor leaves for the next mount of the same session. */
|
||||
export interface ComposerRetainedEditor {
|
||||
selection: ComposerSelection;
|
||||
/** The editor was focused, or meant to regain focus once its pane became active. */
|
||||
export interface ComposerEditingHandoff {
|
||||
snapshot: { text: string; selection: { start: number; end: number } };
|
||||
focused: boolean;
|
||||
replacementKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing state that outlives the editor's mount. Draft text belongs to its draft
|
||||
* owner; the editor keeps its own live buffer across layout modes. Switching between
|
||||
* the compact and desktop screens remounts the editor, so the session carries the
|
||||
* caret and focus intent across that remount while the draft owner carries the text.
|
||||
*/
|
||||
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
|
||||
export class ComposerEditingSession {
|
||||
private fullscreen = false;
|
||||
private retained: ComposerRetainedEditor | null = null;
|
||||
private handoff: ComposerEditingHandoff | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
constructor(private readonly onChange?: () => void) {}
|
||||
getFullscreen = () => this.fullscreen;
|
||||
/** Called by the unmounting editor; the next mount of this session takes it. */
|
||||
retainEditor(retained: ComposerRetainedEditor) {
|
||||
this.retained = retained;
|
||||
}
|
||||
takeEditor(): ComposerRetainedEditor | null {
|
||||
const retained = this.retained;
|
||||
this.retained = null;
|
||||
return retained;
|
||||
}
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
setFullscreen(fullscreen: boolean) {
|
||||
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
|
||||
this.captureForRemount(handoff);
|
||||
if (fullscreen === this.fullscreen) return;
|
||||
this.fullscreen = fullscreen;
|
||||
for (const listener of this.listeners) listener();
|
||||
this.onChange?.();
|
||||
}
|
||||
captureForRemount(handoff: ComposerEditingHandoff) {
|
||||
this.handoff = handoff;
|
||||
}
|
||||
peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null {
|
||||
const handoff = this.handoff;
|
||||
if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey)
|
||||
return handoff;
|
||||
// A replacement command issued since capture supersedes the old editor's live buffer.
|
||||
return {
|
||||
snapshot: {
|
||||
text: replacement.text,
|
||||
selection: { start: replacement.text.length, end: replacement.text.length },
|
||||
},
|
||||
focused: handoff.focused,
|
||||
replacementKey: replacement.key,
|
||||
};
|
||||
}
|
||||
takeHandoff(replacement: TextReplacement) {
|
||||
const handoff = this.peekHandoff(replacement);
|
||||
this.handoff = null;
|
||||
return handoff;
|
||||
}
|
||||
}
|
||||
|
||||
interface TabSessions {
|
||||
targetKey: string;
|
||||
sessions: Map<ComposerInputMode, ComposerEditingSession>;
|
||||
}
|
||||
|
||||
export class ComposerEditingSessions {
|
||||
private workspaces = new Map<string, Map<string, TabSessions>>();
|
||||
private listeners = new Set<() => void>();
|
||||
private notify = () => {
|
||||
for (const listener of this.listeners) listener();
|
||||
};
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
class ComposerEditingSessions {
|
||||
private workspaces = new Map<
|
||||
string,
|
||||
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
|
||||
>();
|
||||
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
|
||||
let workspace = this.workspaces.get(workspaceKey);
|
||||
if (!workspace) {
|
||||
@@ -87,31 +76,17 @@ export class ComposerEditingSessions {
|
||||
}
|
||||
let session = tab.sessions.get(purpose);
|
||||
if (!session) {
|
||||
session = new ComposerEditingSession(this.notify);
|
||||
session = new ComposerEditingSession();
|
||||
tab.sessions.set(purpose, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
/** A tab is fullscreen when any of its composers is; the screen hides its chrome for it. */
|
||||
isTabFullscreen(workspaceKey: string, tabId: string | null): boolean {
|
||||
if (tabId === null) return false;
|
||||
const tab = this.workspaces.get(workspaceKey)?.get(tabId);
|
||||
if (!tab) return false;
|
||||
for (const session of tab.sessions.values()) if (session.getFullscreen()) return true;
|
||||
return false;
|
||||
}
|
||||
retain(workspaceKey: string, tabIds: readonly string[]) {
|
||||
const workspace = this.workspaces.get(workspaceKey);
|
||||
if (!workspace) return;
|
||||
const retained = new Set(tabIds);
|
||||
let changed = false;
|
||||
for (const tabId of workspace.keys()) {
|
||||
if (retained.has(tabId)) continue;
|
||||
workspace.delete(tabId);
|
||||
changed = true;
|
||||
}
|
||||
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
|
||||
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
|
||||
if (changed) this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,16 +140,3 @@ export function useComposerEditingSession(
|
||||
export function useComposerFullscreen(session: ComposerEditingSession): boolean {
|
||||
return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen);
|
||||
}
|
||||
/** Whether the composer of a workspace tab is fullscreen, for the chrome that tab sits under. */
|
||||
export function useComposerTabFullscreen(workspaceKey: string, tabId: string | null): boolean {
|
||||
const sessions = useContext(SessionsContext);
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => sessions?.subscribe(listener) ?? (() => {}),
|
||||
[sessions],
|
||||
);
|
||||
const read = useCallback(
|
||||
() => sessions?.isTabFullscreen(workspaceKey, tabId) ?? false,
|
||||
[sessions, workspaceKey, tabId],
|
||||
);
|
||||
return useSyncExternalStore(subscribe, read, read);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
useComposerEditingSession,
|
||||
useComposerFullscreen,
|
||||
type ComposerEditingSession,
|
||||
} from "./editing-session";
|
||||
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
|
||||
import type { ComposerTextSource } from "./text-source";
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { useStore } from "zustand";
|
||||
@@ -31,7 +27,6 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import {
|
||||
@@ -972,12 +967,6 @@ interface ComposerProps {
|
||||
submitLabel?: string;
|
||||
/** Overrides the mode's default placeholder, for text only the caller can build. */
|
||||
placeholder?: string;
|
||||
/**
|
||||
* The host that also lays out the dock and chrome shares this session so its
|
||||
* fullscreen state and the composer's agree. Defaults to the tab's session for
|
||||
* `inputMode`.
|
||||
*/
|
||||
editingSession?: ComposerEditingSession;
|
||||
}
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
@@ -1193,14 +1182,8 @@ function ComposerVoiceModeButton({
|
||||
}
|
||||
|
||||
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
|
||||
// A compact overlay panel (agent list, explorer) covers the pane without taking its
|
||||
// focus. The composer gives up input from the moment the user asks for the panel,
|
||||
// not when its motion settles, so the exit action and autocomplete never race it.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
|
||||
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused && !coveredByOverlayPanel}>
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<RenderProfile id="ComposerContent">
|
||||
<ComposerContent {...props} />
|
||||
</RenderProfile>
|
||||
@@ -1251,7 +1234,6 @@ function ComposerContentImpl({
|
||||
inputWrapperStyle,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
inputMode = "chat",
|
||||
editingSession: providedEditingSession,
|
||||
readOnly = false,
|
||||
submitLabel,
|
||||
placeholder,
|
||||
@@ -1357,7 +1339,7 @@ function ComposerContentImpl({
|
||||
const nextPendingFileId = useRef(0);
|
||||
const isUploadingFile = pendingFiles.length > 0;
|
||||
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
|
||||
const editingSession = useComposerEditingSession(inputMode, providedEditingSession);
|
||||
const editingSession = useComposerEditingSession(inputMode);
|
||||
const isFullscreen = useComposerFullscreen(editingSession);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
||||
@@ -2268,23 +2250,9 @@ function ComposerContentImpl({
|
||||
[githubSearchItems, selectedAttachments, handleToggleGithubItem],
|
||||
);
|
||||
|
||||
// Fullscreen fills the dock slot: the chain down to the input stretches and drops
|
||||
// the inline padding, which the fullscreen presentation supplies itself.
|
||||
const inputAreaContainerStyle = useMemo(
|
||||
() => [
|
||||
styles.inputAreaContainer,
|
||||
isComposerLocked && styles.inputAreaLocked,
|
||||
isFullscreen && styles.inputAreaFullscreen,
|
||||
],
|
||||
[isComposerLocked, isFullscreen],
|
||||
);
|
||||
const inputAreaContentStyle = useMemo(
|
||||
() => [styles.inputAreaContent, isFullscreen && styles.fullscreenFill],
|
||||
[isFullscreen],
|
||||
);
|
||||
const messageInputContainerStyle = useMemo(
|
||||
() => [styles.messageInputContainer, isFullscreen && styles.fullscreenFill],
|
||||
[isFullscreen],
|
||||
() => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked],
|
||||
[isComposerLocked],
|
||||
);
|
||||
|
||||
const attachmentTray = useMemo(
|
||||
@@ -2395,19 +2363,15 @@ function ComposerContentImpl({
|
||||
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
|
||||
isMessageInputFocused={isMessageInputFocused}
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
isFullscreen ? animatedStaticStyles.fullscreenContainer : animatedStaticStyles.container
|
||||
}
|
||||
>
|
||||
<View style={animatedStaticStyles.container}>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={inputAreaContentStyle}>
|
||||
{isFullscreen ? null : queueList}
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{!isFullscreen && sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={messageInputContainerStyle}>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
@@ -2511,12 +2475,6 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
fullscreenContainer: {
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
@@ -2538,18 +2496,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
inputAreaLocked: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
inputAreaFullscreen: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
alignItems: "stretch",
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
fullscreenFill: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
maxWidth: "100%",
|
||||
},
|
||||
inputAreaContent: {
|
||||
flexShrink: 1,
|
||||
width: "100%",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { BackHandler } from "react-native";
|
||||
|
||||
/** The hardware back button leaves fullscreen instead of the screen. */
|
||||
export function useComposerFullscreenExit(
|
||||
active: boolean,
|
||||
onExit: () => void,
|
||||
): ((node: unknown) => void) | undefined {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
onExit();
|
||||
return true;
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [active, onExit]);
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
|
||||
/**
|
||||
* Escape leaves fullscreen before app shortcuts see it. The registration only claims
|
||||
* keys; focus stays where the user left it because the editor never remounts.
|
||||
*/
|
||||
export function useComposerFullscreenExit(
|
||||
active: boolean,
|
||||
onExit: () => void,
|
||||
): ((node: unknown) => void) | undefined {
|
||||
const layer = useOverlayLayer("modal");
|
||||
return useWebOverlayRegistration({
|
||||
active,
|
||||
layer,
|
||||
manageFocus: false,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
onExit();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { BackHandler, View, useWindowDimensions } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
|
||||
/** Same-window host keeps the IME attached while escaping the dock's clipping. */
|
||||
export function ComposerFullscreenHost({
|
||||
children,
|
||||
onExit,
|
||||
active,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onExit: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
onExit();
|
||||
return true;
|
||||
});
|
||||
return () => handler.remove();
|
||||
}, [active, onExit]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height } = useWindowDimensions();
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
const frame = useAnimatedStyle(() => ({
|
||||
height: Math.max(0, height - layoutShift.value),
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}));
|
||||
return (
|
||||
<Portal hostName={DEFAULT_FLOATING_PANEL_PORTAL_HOST}>
|
||||
<View style={[styles.background, !active && styles.hidden]}>
|
||||
<Animated.View style={[{ width: "100%" }, frame]}>{children}</Animated.View>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
hidden: { display: "none" },
|
||||
background: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo, useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
useOverlayLayer,
|
||||
OverlayLayerProvider,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
|
||||
export function ComposerFullscreenHost({
|
||||
children,
|
||||
onExit,
|
||||
active,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onExit: () => void;
|
||||
active: boolean;
|
||||
}) {
|
||||
const layer = useOverlayLayer("modal");
|
||||
const registration = useWebOverlayRegistration({
|
||||
active,
|
||||
layer,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
onExit();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const [viewport, setViewport] = useState(readVisibleViewport);
|
||||
useLayoutEffect(() => {
|
||||
if (!active) return;
|
||||
const update = () => setViewport(readVisibleViewport());
|
||||
const visualViewport = window.visualViewport;
|
||||
update();
|
||||
visualViewport?.addEventListener("resize", update);
|
||||
visualViewport?.addEventListener("scroll", update);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
visualViewport?.removeEventListener("resize", update);
|
||||
visualViewport?.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [active]);
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
display: active ? ("flex" as const) : ("none" as const),
|
||||
position: "absolute" as const,
|
||||
top: viewport.top,
|
||||
left: viewport.left,
|
||||
width: viewport.width,
|
||||
zIndex: layer,
|
||||
pointerEvents: "auto" as const,
|
||||
height: viewport.height,
|
||||
}),
|
||||
[active, layer, viewport],
|
||||
);
|
||||
return createPortal(
|
||||
<OverlayLayerProvider layer={layer}>
|
||||
<View ref={registration} style={frameStyle}>
|
||||
{children}
|
||||
</View>
|
||||
</OverlayLayerProvider>,
|
||||
getOverlayRoot(),
|
||||
);
|
||||
}
|
||||
|
||||
function readVisibleViewport() {
|
||||
const viewport = window.visualViewport;
|
||||
return {
|
||||
top: viewport?.offsetTop ?? 0,
|
||||
left: viewport?.offsetLeft ?? 0,
|
||||
width: viewport?.width ?? window.innerWidth,
|
||||
height: viewport?.height ?? window.innerHeight,
|
||||
};
|
||||
}
|
||||
@@ -84,7 +84,6 @@ import {
|
||||
computeCanStartDictation,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveRetainedSelection,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
@@ -677,7 +676,13 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={[styles.textInputScrollWrapper, props.fullscreen && styles.fullscreenTextSurface]}>
|
||||
<View
|
||||
style={[
|
||||
styles.textInputScrollWrapper,
|
||||
props.fullscreen && styles.fullscreenTextSurface,
|
||||
props.compact && !props.fullscreen && styles.compactTextSurface,
|
||||
]}
|
||||
>
|
||||
{!isWeb && props.compact && !props.fullscreen ? (
|
||||
<Text
|
||||
accessible={false}
|
||||
@@ -1035,8 +1040,11 @@ function getComposerInputSnapshot(
|
||||
current: ComposerTextInputHandle | null,
|
||||
fallbackText: string,
|
||||
fallbackSelection: ComposerInputSnapshot["selection"],
|
||||
mountedTextArea?: TextAreaHandle | null,
|
||||
): ComposerInputSnapshot {
|
||||
const textArea = getWebTextAreaImpl(current);
|
||||
// The inner DOM ref can detach before the outer editing handle. Keep the mounted
|
||||
// element available until this owner's ref callback captures the remount handoff.
|
||||
const textArea = mountedTextArea ?? getWebTextAreaImpl(current);
|
||||
const text = textArea?.value ?? current?.getText() ?? fallbackText;
|
||||
const start = textArea?.selectionStart ?? fallbackSelection.start;
|
||||
const end = textArea?.selectionEnd ?? fallbackSelection.end;
|
||||
@@ -1261,13 +1269,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const inputWrapperRef = useRef<View | null>(null);
|
||||
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
|
||||
const isInputFocusedRef = useRef(false);
|
||||
const focusIntentRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
const selectionRef = useRef({ start: value.length, end: value.length });
|
||||
const handoff = editingSession.peekHandoff(textReplacement);
|
||||
const focusIntentRef = useRef(Boolean(handoff?.focused));
|
||||
const valueRef = useRef(handoff?.snapshot.text ?? value);
|
||||
const selectionRef = useRef(
|
||||
handoff?.snapshot.selection ?? { start: value.length, end: value.length },
|
||||
);
|
||||
const activeComposerRef = useRef(isActiveComposer);
|
||||
activeComposerRef.current = isActiveComposer;
|
||||
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
|
||||
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
|
||||
const textReplacementRef = useRef(textReplacement);
|
||||
textReplacementRef.current = textReplacement;
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
const updateLiveTextPresence = useCallback((text: string) => {
|
||||
const nextHasLiveText = text.trim().length > 0;
|
||||
@@ -1276,49 +1289,43 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
setHasLiveText(nextHasLiveText);
|
||||
}, []);
|
||||
|
||||
// One editor stays mounted through every layout mode, so this ref only follows
|
||||
// the editor's own mount and unmount.
|
||||
const assignTextInput = useCallback((input: ComposerTextInputHandle | null) => {
|
||||
textInputRef.current = input;
|
||||
if (!isWeb) return;
|
||||
const element = getWebTextAreaImpl(input) as HTMLElement | null;
|
||||
webTextareaRef.current = element;
|
||||
setWebTextArea(element);
|
||||
}, []);
|
||||
const assignTextInput = useCallback(
|
||||
(input: ComposerTextInputHandle | null) => {
|
||||
const previous = textInputRef.current;
|
||||
if (!input && previous) {
|
||||
editingSession.captureForRemount({
|
||||
snapshot: getComposerInputSnapshot(
|
||||
previous,
|
||||
valueRef.current,
|
||||
selectionRef.current,
|
||||
webTextareaRef.current,
|
||||
),
|
||||
focused: previous.isFocused() || isInputFocusedRef.current,
|
||||
replacementKey: appliedTextReplacementKeyRef.current,
|
||||
});
|
||||
}
|
||||
textInputRef.current = input;
|
||||
if (isWeb) {
|
||||
const element = getWebTextAreaImpl(input) as HTMLElement | null;
|
||||
webTextareaRef.current = element;
|
||||
setWebTextArea(element);
|
||||
}
|
||||
if (!input) return;
|
||||
const transfer = editingSession.takeHandoff(textReplacementRef.current);
|
||||
if (transfer) {
|
||||
appliedTextReplacementKeyRef.current = textReplacementRef.current.key;
|
||||
valueRef.current = transfer.snapshot.text;
|
||||
updateLiveTextPresence(transfer.snapshot.text);
|
||||
selectionRef.current = transfer.snapshot.selection;
|
||||
input.replaceText(transfer.snapshot.text, transfer.snapshot.selection);
|
||||
if (transfer.focused && activeComposerRef.current) input.focus();
|
||||
}
|
||||
},
|
||||
[editingSession, updateLiveTextPresence],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
|
||||
}, [isActiveComposer]);
|
||||
// The compact and desktop screens mount the editor in different trees, so a
|
||||
// viewport that crosses the breakpoint remounts it. The draft owner brings the
|
||||
// text back; the session brings the caret and focus intent back. On web the
|
||||
// owner may still hold an unpublished edit while the new editor mounts, so the
|
||||
// caret is placed now against the text on screen and again when the owner's
|
||||
// hydrated text arrives.
|
||||
const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const retained = editingSession.takeEditor();
|
||||
if (retained) {
|
||||
const text = textInputRef.current?.getText() ?? valueRef.current;
|
||||
const selection = resolveRetainedSelection(retained.selection, text.length);
|
||||
retainedSelectionRef.current = retained.selection;
|
||||
selectionRef.current = selection;
|
||||
textInputRef.current?.setSelection(selection);
|
||||
if (retained.focused) {
|
||||
focusIntentRef.current = true;
|
||||
if (activeComposerRef.current) textInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
editingSession.retainEditor({
|
||||
selection: getComposerInputSnapshot(
|
||||
textInputRef.current,
|
||||
valueRef.current,
|
||||
selectionRef.current,
|
||||
).selection,
|
||||
focused: isInputFocusedRef.current || focusIntentRef.current,
|
||||
});
|
||||
};
|
||||
}, [editingSession]);
|
||||
const getLiveText = useCallback(() => valueRef.current, []);
|
||||
const composerHeight = useComposerHeight({
|
||||
getText: getLiveText,
|
||||
@@ -1333,15 +1340,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
|
||||
const resetComposerHeight = measuredComposerHeight?.reset;
|
||||
|
||||
const exitFullscreen = useCallback(() => editingSession.setFullscreen(false), [editingSession]);
|
||||
const enterFullscreen = useCallback(() => editingSession.setFullscreen(true), [editingSession]);
|
||||
|
||||
// The conversation sizes itself against the inline composer; fullscreen covers it.
|
||||
const handleComposerLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height);
|
||||
const changePresentation = useCallback(
|
||||
(fullscreen: boolean) => {
|
||||
if (editingSession.getFullscreen() === fullscreen) return;
|
||||
const snapshot = getComposerInputSnapshot(
|
||||
textInputRef.current,
|
||||
valueRef.current,
|
||||
selectionRef.current,
|
||||
);
|
||||
editingSession.changePresentation(fullscreen, {
|
||||
snapshot,
|
||||
focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current,
|
||||
replacementKey: appliedTextReplacementKeyRef.current,
|
||||
});
|
||||
valueRef.current = snapshot.text;
|
||||
selectionRef.current = snapshot.selection;
|
||||
},
|
||||
[isFullscreen, onHeightChange],
|
||||
[editingSession],
|
||||
);
|
||||
const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]);
|
||||
|
||||
const handleComposerLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
|
||||
[onHeightChange],
|
||||
);
|
||||
|
||||
const replaceText = useCallback(
|
||||
@@ -1406,15 +1427,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
updateComposerHeightForText?.(valueRef.current, textReplacement.text);
|
||||
valueRef.current = textReplacement.text;
|
||||
updateLiveTextPresence(textReplacement.text);
|
||||
const retainedSelection =
|
||||
textReplacement.kind === "hydrated" ? retainedSelectionRef.current : null;
|
||||
retainedSelectionRef.current = null;
|
||||
if (textReplacement.text === "") {
|
||||
textInputRef.current?.reset();
|
||||
} else if (retainedSelection) {
|
||||
const selection = resolveRetainedSelection(retainedSelection, textReplacement.text.length);
|
||||
selectionRef.current = selection;
|
||||
textInputRef.current?.replaceText(textReplacement.text, selection);
|
||||
} else {
|
||||
textInputRef.current?.replaceText(textReplacement.text);
|
||||
}
|
||||
@@ -1682,6 +1696,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
});
|
||||
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
|
||||
|
||||
const getWebTextArea = useCallback(
|
||||
(): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||
}
|
||||
}, [getWebTextArea, isFullscreen]);
|
||||
|
||||
usePasteImagesEffect({
|
||||
textarea: webTextArea,
|
||||
isConnected,
|
||||
@@ -1807,8 +1832,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}, [onFocusChange]);
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
// Fullscreen is nothing but the editor, so a blur there (a retained tab being
|
||||
// hidden, the keyboard dismissed) keeps the intent: showing it again refocuses.
|
||||
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
|
||||
isInputFocusedRef.current = false;
|
||||
setIsInputFocused(false);
|
||||
@@ -1865,9 +1888,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
styles.textInput,
|
||||
mode.isMonospace && styles.textInputMonospace,
|
||||
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
|
||||
isCompact && !isFullscreen && styles.compactTextInput,
|
||||
],
|
||||
[composerHeightStyle, mode.isMonospace, isFullscreen, isCompact],
|
||||
[composerHeightStyle, mode.isMonospace, isFullscreen],
|
||||
);
|
||||
// Static content has no textarea to mirror, so it grows with its own text
|
||||
// instead of the measured input height.
|
||||
@@ -1875,22 +1897,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText],
|
||||
[mode.isMonospace],
|
||||
);
|
||||
// Fullscreen always shows Send; without content it is disabled, and looks it.
|
||||
const isPresentedSendDisabled = resolvePresentationSendDisabled({
|
||||
fullscreen: isFullscreen,
|
||||
disabled: isSendButtonDisabled,
|
||||
hasText: hasLiveText,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmpty: allowEmptySubmit,
|
||||
});
|
||||
const sendButtonCombinedStyle = useMemo(
|
||||
() => [
|
||||
styles.sendButton,
|
||||
submitLabel ? styles.sendButtonLabeled : undefined,
|
||||
isPresentedSendDisabled && styles.buttonDisabled,
|
||||
isSendButtonDisabled && styles.buttonDisabled,
|
||||
],
|
||||
[isPresentedSendDisabled, submitLabel],
|
||||
[isSendButtonDisabled, submitLabel],
|
||||
);
|
||||
const overlayContainerStyle = useMemo(
|
||||
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
|
||||
@@ -1929,7 +1942,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton={canPressLoadingButton}
|
||||
onSubmitLoadingPress={onSubmitLoadingPress}
|
||||
onDefaultSendAction={handleDefaultSendAction}
|
||||
isSendButtonDisabled={isPresentedSendDisabled}
|
||||
isSendButtonDisabled={resolvePresentationSendDisabled({
|
||||
fullscreen: isFullscreen,
|
||||
disabled: isSendButtonDisabled,
|
||||
hasText: hasLiveText,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmpty: allowEmptySubmit,
|
||||
})}
|
||||
submitAccessibilityLabel={submitAccessibilityLabel}
|
||||
sendButtonCombinedStyle={sendButtonCombinedStyle}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
@@ -1948,7 +1968,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton,
|
||||
onSubmitLoadingPress,
|
||||
handleDefaultSendAction,
|
||||
isPresentedSendDisabled,
|
||||
isSendButtonDisabled,
|
||||
hasLiveText,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
submitAccessibilityLabel,
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
@@ -1984,7 +2008,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onBlur={handleInputBlur}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
scrollEnabled={isComposerScrollEnabled}
|
||||
autoFocus={false}
|
||||
autoFocus={
|
||||
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
|
||||
}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onPasteImages={onPasteImages}
|
||||
@@ -2003,6 +2029,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[
|
||||
readOnly,
|
||||
textReplacement,
|
||||
editingSession,
|
||||
isActiveComposer,
|
||||
assignTextInput,
|
||||
textInputStyle,
|
||||
readOnlyTextStyle,
|
||||
@@ -2134,10 +2162,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
handleRealtimeVoiceStop,
|
||||
],
|
||||
);
|
||||
const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]);
|
||||
return (
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={isFullscreen ? styles.fullscreenContainer : styles.container}
|
||||
style={styles.container}
|
||||
testID="message-input-root"
|
||||
onLayout={handleComposerLayout}
|
||||
>
|
||||
@@ -2148,7 +2177,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
/>
|
||||
<ComposerInputPresentation
|
||||
fullscreen={isFullscreen}
|
||||
sendIconSize={buttonIconSize}
|
||||
iconSize={buttonIconSize}
|
||||
active={isActiveComposer}
|
||||
surface={textSurface}
|
||||
toolbar={toolbar}
|
||||
send={sendButton}
|
||||
@@ -2177,10 +2207,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
fullscreenContainer: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
inputWrapper: {
|
||||
flexShrink: 1,
|
||||
flexDirection: "column",
|
||||
@@ -2215,18 +2241,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
inputWrapperReadOnly: {
|
||||
borderStyle: "dotted",
|
||||
},
|
||||
// The band the fullscreen glyph sits in is the input's own padding, so a tap
|
||||
// anywhere in it (under the glyph, or where the glyph would be) is a tap on the
|
||||
// editor: it focuses, shows the keyboard, and places the caret. Reserved whether
|
||||
// or not the glyph shows, so text never reflows when it appears.
|
||||
compactTextInput: { paddingRight: 36 },
|
||||
compactTextSurface: { paddingRight: 36 },
|
||||
fullscreenTextSurface: { flex: 1, minHeight: 0 },
|
||||
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
|
||||
lineMeasurement: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
right: 36,
|
||||
opacity: 0,
|
||||
maxHeight: undefined,
|
||||
minHeight: undefined,
|
||||
|
||||
@@ -3,31 +3,19 @@ import { View, type ViewProps } from "react-native";
|
||||
import { Maximize2, Minimize2 } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useComposerFullscreenExit } from "./fullscreen-exit";
|
||||
import { resolveGlyphRailOffset } from "./state";
|
||||
|
||||
const CONTROL_SIZE = 28;
|
||||
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
|
||||
import { ComposerFullscreenHost } from "./fullscreen-host";
|
||||
|
||||
function keepEditorFocused(event: import("react-native").PointerEvent) {
|
||||
if (isWeb) event.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* One tree for both layout modes. The editing surface keeps its position so the
|
||||
* mounted editor, its native buffer, selection, and focus survive every toggle;
|
||||
* only the chrome around it changes. Fullscreen shows exit top right and Send
|
||||
* bottom right, both on the rail the inline Send button uses; a send error sits
|
||||
* in the same footer as Send.
|
||||
*/
|
||||
/** Both presentations receive the same editing surface and send capability. */
|
||||
export function ComposerInputPresentation({
|
||||
fullscreen,
|
||||
sendIconSize,
|
||||
iconSize,
|
||||
active,
|
||||
surface,
|
||||
toolbar,
|
||||
send,
|
||||
@@ -42,7 +30,8 @@ export function ComposerInputPresentation({
|
||||
overlay,
|
||||
}: {
|
||||
fullscreen: boolean;
|
||||
sendIconSize: number;
|
||||
iconSize: number;
|
||||
active: boolean;
|
||||
surface: ReactNode;
|
||||
toolbar: ReactNode;
|
||||
send: ReactNode;
|
||||
@@ -57,58 +46,33 @@ export function ComposerInputPresentation({
|
||||
overlay: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// A retained tab keeps its fullscreen editor mounted while hidden; the global exit
|
||||
// action (Escape, hardware back) belongs only to the pane the user is in.
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit);
|
||||
const railOffset = resolveGlyphRailOffset({
|
||||
controlSize: CONTROL_SIZE,
|
||||
sendIconSize,
|
||||
glyphIconSize: FULLSCREEN_GLYPH_SIZE,
|
||||
});
|
||||
// ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox.
|
||||
// Compensate for that difference to align visible ink with Send, not just boxes.
|
||||
const railAdjustment = iconSize / 12;
|
||||
const maximizeIcon = useCallback(
|
||||
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
|
||||
[],
|
||||
(color: string) => <Maximize2 color={color} size={iconSize} />,
|
||||
[iconSize],
|
||||
);
|
||||
const minimizeIcon = useCallback(
|
||||
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
|
||||
[],
|
||||
(color: string) => <Minimize2 color={color} size={iconSize} />,
|
||||
[iconSize],
|
||||
);
|
||||
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
|
||||
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
|
||||
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
|
||||
return (
|
||||
<View
|
||||
ref={exitScopeRef}
|
||||
style={fullscreen ? styles.fullscreen : styles.inline}
|
||||
testID={fullscreen ? "composer-fullscreen" : undefined}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<View style={styles.exitRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
accessibilityLabel={t("composer.input.exitFullscreen")}
|
||||
onPointerDown={keepEditorFocused}
|
||||
onPress={onExit}
|
||||
style={exitStyle}
|
||||
leftIcon={minimizeIcon}
|
||||
hitSlop={8}
|
||||
>
|
||||
{null}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
<ComposerFullscreenBody fullscreen={fullscreen}>
|
||||
<View
|
||||
ref={inputWrapperRef}
|
||||
style={fullscreen ? styles.fullscreenInputWrapper : inputWrapperStyle}
|
||||
pointerEvents={inputPointerEvents}
|
||||
>
|
||||
{fullscreen ? null : attachmentSlot}
|
||||
<View style={fullscreen ? styles.fullscreenTextSurface : styles.inlineTextSurface}>
|
||||
const enterStyle = useMemo(
|
||||
() => [styles.fullscreenButton, { right: -6 + railAdjustment }],
|
||||
[railAdjustment],
|
||||
);
|
||||
const exitStyle = useMemo(
|
||||
() => [styles.iconButton, { marginRight: railAdjustment }],
|
||||
[railAdjustment],
|
||||
);
|
||||
if (!fullscreen)
|
||||
return (
|
||||
<>
|
||||
<View ref={inputWrapperRef} style={inputWrapperStyle} pointerEvents={inputPointerEvents}>
|
||||
{attachmentSlot}
|
||||
<View style={styles.inlineTextSurface}>
|
||||
{surface}
|
||||
{showFullscreenButton && !fullscreen ? (
|
||||
{showFullscreenButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -123,35 +87,50 @@ export function ComposerInputPresentation({
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
{fullscreen ? null : toolbar}
|
||||
{toolbar}
|
||||
</View>
|
||||
</ComposerFullscreenBody>
|
||||
{fullscreen ? (
|
||||
<ComposerFullscreenFooter style={styles.footer}>
|
||||
{error}
|
||||
<View style={styles.sendRow}>{send}</View>
|
||||
</ComposerFullscreenFooter>
|
||||
) : null}
|
||||
{overlay}
|
||||
</View>
|
||||
{overlay}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ComposerFullscreenHost onExit={onExit} active={active}>
|
||||
<View style={styles.fullscreen} testID="composer-fullscreen">
|
||||
<View style={styles.exitRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
accessibilityLabel={t("composer.input.exitFullscreen")}
|
||||
onPointerDown={keepEditorFocused}
|
||||
onPress={onExit}
|
||||
style={exitStyle}
|
||||
leftIcon={minimizeIcon}
|
||||
hitSlop={8}
|
||||
>
|
||||
{null}
|
||||
</Button>
|
||||
</View>
|
||||
{surface}
|
||||
{error}
|
||||
<View style={styles.sendRow}>{send}</View>
|
||||
</View>
|
||||
</ComposerFullscreenHost>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
inline: { flexShrink: 1, position: "relative" },
|
||||
inlineTextSurface: { flexShrink: 1, position: "relative" },
|
||||
iconButton: {
|
||||
width: CONTROL_SIZE,
|
||||
height: CONTROL_SIZE,
|
||||
width: 28,
|
||||
height: 28,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
enterButton: {
|
||||
fullscreenButton: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: CONTROL_SIZE,
|
||||
height: CONTROL_SIZE,
|
||||
width: 28,
|
||||
height: 28,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
@@ -162,10 +141,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
fullscreenInputWrapper: { flex: 1, minHeight: 0 },
|
||||
fullscreenTextSurface: { flex: 1, minHeight: 0 },
|
||||
exitRow: { alignItems: "flex-end", flexShrink: 0 },
|
||||
// The send error rides the keyboard with Send; below the body it would sit behind it.
|
||||
footer: { flexShrink: 0, gap: theme.spacing[3] },
|
||||
sendRow: { alignItems: "flex-end" },
|
||||
sendRow: { flexShrink: 0, alignItems: "flex-end" },
|
||||
}));
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
resolveActiveSendBehavior,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveGlyphRailOffset,
|
||||
resolveRetainedSelection,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
@@ -335,30 +333,10 @@ describe("composer surface text", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the live buffer when the initial publication is already applied", () => {
|
||||
it("uses the live handoff when its replacement has already been applied", () => {
|
||||
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
|
||||
expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe(
|
||||
"Live edited draft",
|
||||
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
|
||||
"Live remount handoff",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retained selection", () => {
|
||||
it("keeps a caret that fits and clamps one that no longer does", () => {
|
||||
expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 });
|
||||
expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 });
|
||||
expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 });
|
||||
expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fullscreen glyph rail", () => {
|
||||
it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => {
|
||||
expect(
|
||||
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }),
|
||||
).toBeCloseTo(4 / 3);
|
||||
expect(
|
||||
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }),
|
||||
).toBeCloseTo(1 / 6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,24 +193,6 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<
|
||||
await ctx.voice.stopVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in
|
||||
* equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at
|
||||
* x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen
|
||||
* glyphs visibly past the arrow.
|
||||
*/
|
||||
export function resolveGlyphRailOffset(input: {
|
||||
controlSize: number;
|
||||
sendIconSize: number;
|
||||
glyphIconSize: number;
|
||||
}): number {
|
||||
const arrowInkInset =
|
||||
(input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24;
|
||||
const glyphInkInset =
|
||||
(input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24;
|
||||
return arrowInkInset - glyphInkInset;
|
||||
}
|
||||
|
||||
export function resolveComposerSurfaceText(
|
||||
liveText: string,
|
||||
replacement: TextReplacement,
|
||||
@@ -218,13 +200,3 @@ export function resolveComposerSurfaceText(
|
||||
): string {
|
||||
return replacement.key === appliedReplacementKey ? liveText : replacement.text;
|
||||
}
|
||||
|
||||
/** A retained caret must fit the text the remounted editor shows. */
|
||||
export function resolveRetainedSelection(
|
||||
retained: { start: number; end: number },
|
||||
textLength: number,
|
||||
): { start: number; end: number } {
|
||||
const end = Math.min(Math.max(retained.end, 0), textLength);
|
||||
const start = Math.min(Math.max(retained.start, 0), end);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
@@ -9,12 +9,8 @@ export interface MessagePayload {
|
||||
forceSend?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text the draft owner hands the editor. `hydrated` is the owner's answer to a mount:
|
||||
* the draft's text as persisted or still pending from the editor that unmounted.
|
||||
*/
|
||||
export interface TextReplacement {
|
||||
kind: "initial" | "hydrated" | "replace";
|
||||
kind: "initial" | "replace";
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -199,71 +199,6 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
||||
}
|
||||
|
||||
describe("server data push router", () => {
|
||||
it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
const options = (mode: "base" | "uncommitted", enabled: boolean) => {
|
||||
const queryKey = checkoutDiffQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
mode === "base" ? "main" : undefined,
|
||||
);
|
||||
return {
|
||||
queryKey,
|
||||
queryFn: skipToken as typeof skipToken,
|
||||
gcTime: Infinity,
|
||||
meta: checkoutDiffPushRoute({
|
||||
enabled,
|
||||
serverId,
|
||||
cwd,
|
||||
subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`,
|
||||
compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const visible = new QueryObserver(queryClient, options("base", true));
|
||||
const hidden = new QueryObserver(queryClient, options("base", false));
|
||||
const unsubscribeVisible = visible.subscribe(() => {});
|
||||
const unsubscribeHidden = hidden.subscribe(() => {});
|
||||
const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId });
|
||||
try {
|
||||
visible.setOptions(options("uncommitted", true));
|
||||
hidden.setOptions(options("uncommitted", false));
|
||||
const subscription = fake.subscribeCheckoutDiffCalls.findLast(
|
||||
(call) => call.compare.mode === "uncommitted",
|
||||
)!;
|
||||
const files = [
|
||||
{
|
||||
path: "README.md",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
},
|
||||
];
|
||||
fake.emit({
|
||||
type: "checkout_diff_update",
|
||||
payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null },
|
||||
});
|
||||
expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({
|
||||
files,
|
||||
});
|
||||
expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId);
|
||||
|
||||
unsubscribeVisible();
|
||||
expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId);
|
||||
} finally {
|
||||
unsubscribeVisible();
|
||||
unsubscribeHidden();
|
||||
unmount();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains equal diff files without reconstructing changed file bodies", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
|
||||
@@ -494,14 +494,6 @@ function getActiveServerDataRoute(
|
||||
if (query.getObserversCount() === 0) {
|
||||
return null;
|
||||
}
|
||||
// Query metadata belongs to the last observer that updated its options. A
|
||||
// retained hidden panel must not cancel demand from another visible panel.
|
||||
const observerRoutes = query.observers
|
||||
.map((observer) => getServerDataRoute({ meta: observer.options.meta }))
|
||||
.filter((route): route is ServerDataRoute => route !== null);
|
||||
if (observerRoutes.length > 0) {
|
||||
return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null;
|
||||
}
|
||||
const route = getServerDataRoute(query);
|
||||
if (route) {
|
||||
return route.enabled && route.serverId === serverId ? route : null;
|
||||
@@ -598,7 +590,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"])
|
||||
);
|
||||
}
|
||||
|
||||
function getServerDataRoute(query: Pick<Query, "meta">): ServerDataRoute | null {
|
||||
function getServerDataRoute(query: Query): ServerDataRoute | null {
|
||||
const meta = query.meta;
|
||||
if (!isRecord(meta) || !isRecord(meta.serverData)) {
|
||||
return null;
|
||||
|
||||
@@ -80,6 +80,7 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps)
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
}),
|
||||
enabled: Boolean(pairingQuery.data?.url),
|
||||
dataShape: "value",
|
||||
@@ -274,8 +275,7 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
|
||||
return (
|
||||
<SvgXml
|
||||
xml={svg}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={styles.qrImage}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("pairing.device.qrAccessibility")}
|
||||
/>
|
||||
@@ -365,6 +365,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.palette.white,
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
linkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -165,7 +165,4 @@ const FRAME_STYLE = {
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
// CodeMirror's scrollIntoView scrolls every overflowing ancestor and stops at a fixed or sticky
|
||||
// one. Sticky (with no offsets it lays out like relative) keeps line reveals inside the editor, so
|
||||
// they don't shift the host around it, such as the compact workspace sheet.
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden", position: "sticky" } as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
|
||||
describe("resolveMarkdownFileDirectory", () => {
|
||||
it("uses the parent of an absolute markdown path", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "/Users/test/project/docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs");
|
||||
});
|
||||
|
||||
it("joins a workspace-relative markdown path to the workspace root", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "docs/星谷云/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs/星谷云");
|
||||
});
|
||||
|
||||
it("preserves the host home directory for home-relative markdown paths", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "~/.paseo/plans/note.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/plans");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMarkdownPreviewImageSource", () => {
|
||||
it("passes through remote and data URIs", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "https://example.com/image.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("https://example.com/image.png");
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "data:image/png;base64,abc",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("data:image/png;base64,abc");
|
||||
});
|
||||
|
||||
it("resolves a sibling image against the markdown file", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./images/hero.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs/images/hero.png");
|
||||
});
|
||||
|
||||
it("ignores local URL query strings and fragments", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./images/hero.png?v=2#preview",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/workspace/docs/images/hero.png");
|
||||
});
|
||||
|
||||
it("preserves encoded filename characters when stripping URL suffixes", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./hero%23draft%3Fv.png?v=2#preview",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/workspace/docs/hero#draft?v.png");
|
||||
});
|
||||
|
||||
it("preserves remote URL query strings and fragments", () => {
|
||||
const source = "https://example.com/hero.png?v=2#preview";
|
||||
expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe(
|
||||
source,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves parent-relative images that stay inside the workspace", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "../../data/cache/xinggu_docs/12-linkedin/token.png",
|
||||
markdownPath: "docs/星谷云/12-LinkedIn.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png");
|
||||
});
|
||||
|
||||
it("keeps absolute and home-relative image paths", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "/tmp/screenshot.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/tmp/screenshot.png");
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "~/.paseo/screenshots/output.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/screenshots/output.png");
|
||||
});
|
||||
|
||||
it("normalizes file URIs into host paths", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "file:///tmp/paseo-preview.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/tmp/paseo-preview.png");
|
||||
});
|
||||
|
||||
it("resolves images beside a home-relative document without using the workspace root", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "screenshots/output.png",
|
||||
markdownPath: "~/.paseo/plans/note.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/plans/screenshots/output.png");
|
||||
});
|
||||
|
||||
it("decodes a file URI exactly once", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "file:///tmp/hero%2520image.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/tmp/hero%20image.png");
|
||||
});
|
||||
|
||||
it("resolves parent segments from a home-relative document", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "../images/hero.png",
|
||||
markdownPath: "~/docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("~/images/hero.png");
|
||||
});
|
||||
|
||||
it("resolves Windows relative images against a drive-rooted markdown file", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "..\\images\\hero.png",
|
||||
markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md",
|
||||
workspaceRoot: "C:\\Users\\test\\repo",
|
||||
}),
|
||||
).toBe("C:/Users/test/repo/images/hero.png");
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { localFileSourceToPath } from "@/attachments/utils";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
function trimTrailingSeparators(value: string): string {
|
||||
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
return value.replace(/[\\/]+$/, "");
|
||||
}
|
||||
|
||||
function normalizePathSegments(value: string): string {
|
||||
const segments: string[] = [];
|
||||
for (const segment of value.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (segments.length > 0) {
|
||||
segments.pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function normalizeAbsolutePath(value: string): string | null {
|
||||
const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/"));
|
||||
if (isHomeRelativePath(normalizedInput)) {
|
||||
return `~/${normalizePathSegments(normalizedInput.slice(2))}`;
|
||||
}
|
||||
if (!isAbsolutePath(normalizedInput)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
|
||||
if (drivePath) {
|
||||
return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`);
|
||||
}
|
||||
|
||||
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
|
||||
return trimTrailingSeparators(
|
||||
`${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parentDirectory(path: string): string | null {
|
||||
const normalized = trimTrailingSeparators(path.replace(/\\/g, "/"));
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) {
|
||||
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
}
|
||||
|
||||
const index = normalized.lastIndexOf("/");
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
if (index === 0) {
|
||||
return "/";
|
||||
}
|
||||
const parent = normalized.slice(0, index);
|
||||
if (/^[A-Za-z]:$/.test(parent)) {
|
||||
return `${parent}/`;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
function joinAbsolute(baseDirectory: string, relativePath: string): string | null {
|
||||
const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/"));
|
||||
const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!directory) {
|
||||
return null;
|
||||
}
|
||||
return normalizeAbsolutePath(`${directory}/${relative}`);
|
||||
}
|
||||
|
||||
function isHomeRelativePath(value: string): boolean {
|
||||
return value === "~" || value.startsWith("~/") || value.startsWith("~\\");
|
||||
}
|
||||
|
||||
function decodeRelativeSource(source: string): string {
|
||||
try {
|
||||
return decodeURIComponent(source);
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMarkdownFileDirectory(input: {
|
||||
markdownPath: string;
|
||||
workspaceRoot?: string;
|
||||
}): string | null {
|
||||
const markdownPath = input.markdownPath.trim().replace(/\\/g, "/");
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) {
|
||||
const absolute = normalizeAbsolutePath(markdownPath);
|
||||
return absolute ? parentDirectory(absolute) : null;
|
||||
}
|
||||
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath);
|
||||
return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null;
|
||||
}
|
||||
|
||||
export function resolveMarkdownPreviewImageSource(input: {
|
||||
source: string;
|
||||
markdownPath: string;
|
||||
workspaceRoot?: string;
|
||||
}): string | null {
|
||||
const source = input.source.trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^(https?:|data:|blob:)/i.test(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const pathSource = source.replace(/[?#].*$/, "");
|
||||
const path = localFileSourceToPath(
|
||||
pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource),
|
||||
);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isHomeRelativePath(path) || isAbsolutePath(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const directory = resolveMarkdownFileDirectory({
|
||||
markdownPath: input.markdownPath,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
});
|
||||
if (!directory) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return joinAbsolute(directory, path);
|
||||
}
|
||||
@@ -1,59 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ASTNode, RenderRules } from "react-native-markdown-display";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
createSharedMarkdownRules,
|
||||
MarkdownRenderer,
|
||||
type MarkdownStyles,
|
||||
} from "@/components/markdown/renderer";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { parseMarkdownPreviewDocument } from "./document";
|
||||
import { resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
import { FileMarkdownPreviewImage } from "./preview-image";
|
||||
|
||||
export function FileMarkdownPreview({
|
||||
source,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
filePath: string;
|
||||
workspaceRoot?: string;
|
||||
client?: DaemonClient | null;
|
||||
serverId?: string;
|
||||
}) {
|
||||
export function FileMarkdownPreview({ source }: { source: string }) {
|
||||
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
|
||||
const rules = useMemo<RenderRules>(
|
||||
() => ({
|
||||
...createSharedMarkdownRules(),
|
||||
image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => {
|
||||
const resolved = resolveMarkdownPreviewImageSource({
|
||||
source: String(node.attributes?.src ?? ""),
|
||||
markdownPath: filePath,
|
||||
workspaceRoot,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FileMarkdownPreviewImage
|
||||
key={node.key}
|
||||
source={resolved}
|
||||
occurrenceKey={`${filePath}:${node.key}`}
|
||||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[client, filePath, serverId, workspaceRoot],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.outerGutter}>
|
||||
@@ -79,7 +31,7 @@ export function FileMarkdownPreview({
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<MarkdownRenderer text={document.body} rules={rules} />
|
||||
<MarkdownRenderer text={document.body} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const PREVIEW_IMAGE_MIN_HEIGHT = 160;
|
||||
|
||||
export function FileMarkdownPreviewImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
alt,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
occurrenceKey: string;
|
||||
alt?: string;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const image = useAssistantImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
});
|
||||
const binding = image.status === "failed" ? null : image.binding;
|
||||
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
|
||||
const imageUri = binding?.uri ?? "";
|
||||
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
|
||||
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||||
if (aspectRatio) {
|
||||
return { aspectRatio };
|
||||
}
|
||||
return { height: PREVIEW_IMAGE_MIN_HEIGHT };
|
||||
}, [aspectRatio]);
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.surface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
|
||||
if (!viewerOpen || !imageUri) return null;
|
||||
return {
|
||||
type: "uri",
|
||||
uri: imageUri,
|
||||
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
|
||||
};
|
||||
}, [aspectRatio, imageUri, viewerOpen]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-failed">
|
||||
<Text style={styles.errorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-loading">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.frame} testID="markdown-preview-image">
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
disabled={image.status !== "loaded"}
|
||||
onPress={openViewer}
|
||||
style={surfaceStyle}
|
||||
>
|
||||
<View style={styles.image} accessibilityRole="image" accessibilityLabel={alt}>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={styles.loadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
frame: {
|
||||
width: "100%",
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
surface: {
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
state: {
|
||||
minHeight: PREVIEW_IMAGE_MIN_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -54,9 +54,6 @@ interface FilePreviewBodyProps {
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
@@ -138,9 +135,6 @@ function FilePreviewBody({
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
@@ -188,13 +182,7 @@ function FilePreviewBody({
|
||||
style={styles.previewContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<FileMarkdownPreview
|
||||
source={preview.content ?? ""}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
<FileMarkdownPreview source={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -309,7 +297,6 @@ export function FilePane({
|
||||
return (
|
||||
<FilePanePresentation
|
||||
serverId={serverId}
|
||||
workspaceRoot={normalizedWorkspaceRoot}
|
||||
client={client}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
@@ -351,7 +338,6 @@ function isEditableTextFile(input: {
|
||||
|
||||
function FilePanePresentation({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
@@ -373,7 +359,6 @@ function FilePanePresentation({
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
@@ -408,8 +393,6 @@ function FilePanePresentation({
|
||||
return (
|
||||
<EditableFilePane
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
@@ -466,17 +449,12 @@ function FilePanePresentation({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
cwd,
|
||||
path,
|
||||
@@ -492,8 +470,6 @@ function EditableFilePane({
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
path: string;
|
||||
@@ -646,9 +622,6 @@ function EditableFilePane({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={null}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
|
||||
@@ -12,9 +12,8 @@ interface UseFileDownloadParams {
|
||||
/**
|
||||
* Returns a stable callback that downloads a single workspace file by its
|
||||
* workspace-relative path. Shared by the file explorer tree and the git diff
|
||||
* pane so both surfaces download through the same download-store pipeline instead
|
||||
* of duplicating the plumbing: the HTTP token download over a direct TCP connection,
|
||||
* the session socket over anything else.
|
||||
* pane so both surfaces download through the same host token + download-store
|
||||
* pipeline instead of duplicating the plumbing.
|
||||
*/
|
||||
export function useFileDownload({
|
||||
serverId,
|
||||
@@ -31,7 +30,7 @@ export function useFileDownload({
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
|
||||
const { requestFileDownloadToken } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
@@ -49,18 +48,9 @@ export function useFileDownload({
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
readFile: requestFileBytes,
|
||||
});
|
||||
},
|
||||
[
|
||||
daemonProfile,
|
||||
requestFileBytes,
|
||||
requestFileDownloadToken,
|
||||
serverId,
|
||||
startDownload,
|
||||
workspaceScopeId,
|
||||
],
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,21 +253,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
);
|
||||
|
||||
const requestFileBytes = useCallback(
|
||||
async (path: string, maxBytes: number) => {
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
throw new Error(t("workspace.fileExplorer.states.unavailable"));
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
// No request timeout: a large file over a slow relay legitimately takes longer than the
|
||||
// 60 s default, and a dead connection still rejects on disconnect.
|
||||
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
);
|
||||
|
||||
const createEntry = useCallback(
|
||||
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
|
||||
if (!client || !normalizedWorkspaceRoot) {
|
||||
@@ -357,7 +342,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
requestFileBytes,
|
||||
createEntry,
|
||||
renameEntry,
|
||||
duplicateEntry,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
parseClampedFontSize,
|
||||
parseTerminalScrollbackLines,
|
||||
resolveContentMaxWidth,
|
||||
sanitizeFontFamily,
|
||||
saveAppSettings,
|
||||
type SettingsDeps,
|
||||
} from "./storage";
|
||||
@@ -1095,34 +1094,3 @@ describe("content max width", () => {
|
||||
expect((await load("wide")).contentMaxWidth).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("font family CSS safety", () => {
|
||||
it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])(
|
||||
"rejects unfinished CSS syntax: %s",
|
||||
(value) => expect(sanitizeFontFamily(value)).toBeNull(),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"Menlo",
|
||||
"'Fira Code', monospace",
|
||||
'"Fira Code", monospace',
|
||||
"'Font\\'s name'",
|
||||
"Font\\ Name",
|
||||
"'Font (Mono)'",
|
||||
"'Font [Mono]'",
|
||||
])("preserves complete font families: %s", (value) =>
|
||||
expect(sanitizeFontFamily(value)).toBe(value),
|
||||
);
|
||||
|
||||
it.each(["uiFontFamily", "monoFontFamily"])(
|
||||
"recovers a persisted unmatched quote in %s",
|
||||
async (field) => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }),
|
||||
}),
|
||||
});
|
||||
expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -522,24 +522,7 @@ export function sanitizeFontFamily(value: unknown): string | null {
|
||||
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
|
||||
return null; // control chars would corrupt the font-family string
|
||||
}
|
||||
// Unistyles serializes theme values into one shared stylesheet. An unfinished
|
||||
// string, escape, comment, or block consumes the declarations that follow.
|
||||
let quote: string | null = null;
|
||||
let escaped = false;
|
||||
for (const char of trimmed) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (char === "\\") {
|
||||
escaped = true;
|
||||
} else if (quote !== null) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
} else if (/[()[\]/*]/.test(char)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return quote === null && !escaped ? trimmed : null;
|
||||
return trimmed; // quotes/commas are legit in stacks
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
|
||||
|
||||
@@ -613,9 +613,6 @@ export const ar: TranslationResources = {
|
||||
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
||||
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "فتح كعلامة تبويب",
|
||||
},
|
||||
tabs: {
|
||||
loading: "تحميل...",
|
||||
modified: "تغييرات غير محفوظة",
|
||||
|
||||
@@ -610,9 +610,6 @@ export const en = {
|
||||
updateHost: "Update the host to use the native terminal renderer.",
|
||||
unableToSubscribe: "Unable to subscribe to terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Open as tab",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Loading...",
|
||||
modified: "Unsaved changes",
|
||||
|
||||
@@ -619,9 +619,6 @@ export const es: TranslationResources = {
|
||||
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
|
||||
unableToSubscribe: "No se puede suscribir al terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Abrir como pestaña",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Cargando...",
|
||||
modified: "Cambios sin guardar",
|
||||
|
||||
@@ -618,9 +618,6 @@ export const fr: TranslationResources = {
|
||||
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
|
||||
unableToSubscribe: "Impossible de s’abonner au terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Ouvrir dans un onglet",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Chargement…",
|
||||
modified: "Modifications non enregistrées",
|
||||
|
||||
@@ -619,9 +619,6 @@ export const ja: TranslationResources = {
|
||||
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
||||
unableToSubscribe: "ターミナルに接続できません",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "タブで開く",
|
||||
},
|
||||
tabs: {
|
||||
loading: "読み込み中...",
|
||||
modified: "未保存の変更",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user