mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d09db85e89 | ||
|
|
6ad124758c | ||
|
|
f3ddf99fe3 | ||
|
|
404d08d24b | ||
|
|
6d43a8c5b0 | ||
|
|
e1a5664d9b | ||
|
|
b58af43a06 | ||
|
|
1bf3a57a1c | ||
|
|
56448842f0 | ||
|
|
14e6384516 | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed | ||
|
|
a4b15455b7 | ||
|
|
bd9af256bd | ||
|
|
b1a638482d | ||
|
|
12aaded6c5 | ||
|
|
0614e96ee0 | ||
|
|
e135e9c101 | ||
|
|
335a11eb82 | ||
|
|
17562fcb66 |
@@ -54,7 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
|
||||
@@ -212,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ contracts.
|
||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||
| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
@@ -22,6 +23,29 @@ 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.
|
||||
@@ -62,7 +86,9 @@ lifecycle.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
|
||||
mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
|
||||
pane, so callers never check whether splits render.
|
||||
|
||||
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
|
||||
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
||||
|
||||
+37
-1
@@ -198,6 +198,40 @@ 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
|
||||
@@ -304,7 +338,9 @@ track phase.
|
||||
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
||||
includes native centered dialogs: separate React Native Modals cannot present a root-owned
|
||||
sibling dialog over an existing iOS presenter.
|
||||
sibling dialog over an existing iOS presenter. The one exception is the
|
||||
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
|
||||
whole workspace panel.
|
||||
|
||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||
|
||||
@@ -21,6 +21,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Workspace sheet** — 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).
|
||||
|
||||
@@ -86,6 +86,12 @@ 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,6 +223,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
|
||||
import {
|
||||
closeWorkspaceSheet,
|
||||
expectOpenTabCount,
|
||||
openWorkspaceSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
expectWorkspaceSheetAtSnap,
|
||||
replaceWorkspaceSheetFileText,
|
||||
selectTabFromSwitcher,
|
||||
tapWorkspaceSheetBackdrop,
|
||||
workspaceSheet,
|
||||
} from "../support/helpers/workspace-sheet";
|
||||
|
||||
const FILE_LINK = "linked.ts:3";
|
||||
|
||||
function promptMentioning(target: string): string {
|
||||
return [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-workspace-sheet-",
|
||||
title: "Sheet parent",
|
||||
initialPrompt: promptMentioning(FILE_LINK),
|
||||
});
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const linkedPath = path.join(workspace.cwd, "linked.ts");
|
||||
await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
|
||||
const child = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||
);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
await test.step("a chat file link opens the file in a sheet", async () => {
|
||||
const fileLink = page.getByText(FILE_LINK, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("three")).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, "linked.ts");
|
||||
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
await closeWorkspaceSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet without its composer", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const sheet = workspaceSheet(page, child.title);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, child.title);
|
||||
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab", async () => {
|
||||
await openWorkspaceSheetAsTab(page, child.title);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
|
||||
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
|
||||
await selectTabFromSwitcher(page, "Sheet parent");
|
||||
await page.getByText(FILE_LINK, { exact: true }).click();
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
// Hold the autosave so the edit stays unsaved.
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
await expectDeclinedDiscardPrompt(page, () =>
|
||||
sheet.getByRole("button", { name: "Close" }).click(),
|
||||
);
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,12 @@ 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 { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
@@ -96,16 +101,6 @@ 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();
|
||||
@@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
// 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 expectMobileAgentSidebarVisible(page);
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
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");
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
queueComposerWithButton,
|
||||
configureComposerQueueSend,
|
||||
activateRetainedComposerTab,
|
||||
switchComposerTabWithKeyboard,
|
||||
expectWorkspaceHeaderHidden,
|
||||
expectWorkspaceHeaderVisible,
|
||||
recordVisibleComposerGeometry,
|
||||
expectNoCollapsedVisibleComposer,
|
||||
expectAttachmentPill,
|
||||
@@ -27,8 +30,14 @@ 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);
|
||||
@@ -156,6 +165,36 @@ 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);
|
||||
@@ -188,47 +227,107 @@ test("compact fullscreen editing preserves the live draft and selection and stay
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await attachComposerTestImage(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await writeCompactDraft(page, "First line\nSecond line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await writeCompactDraft(
|
||||
page,
|
||||
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
|
||||
4,
|
||||
),
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
|
||||
await selectComposerDraftRange(page, 4, 17);
|
||||
await enterComposerFullscreen(page);
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
|
||||
await selectComposerDraftRange(page, 3, 12);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(composerLocator(page)).toHaveValue(
|
||||
"Edited fullscreen draft\nSecond line\nThird line",
|
||||
);
|
||||
await expectComposerDraftSelection(page, 3, 12);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await selectComposerDraftRange(page, 0, 5);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 0, 5);
|
||||
await expect(composerLocator(page)).toHaveValue("Short draft");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await 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 sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
|
||||
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 expect(composerLocator(page)).toBeFocused();
|
||||
await composerLocator(page).press("Y");
|
||||
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
@@ -253,6 +352,7 @@ 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();
|
||||
@@ -264,6 +364,76 @@ 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,
|
||||
}) => {
|
||||
@@ -333,15 +503,28 @@ 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);
|
||||
await activateRetainedComposerTab(page, first.title);
|
||||
// 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 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 expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await expectComposerDraftSelection(page, 4, 17);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
expect(await pasteComposerTestImage(page)).toBe(true);
|
||||
@@ -352,6 +535,36 @@ 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,
|
||||
}) => {
|
||||
@@ -408,30 +621,6 @@ 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-",
|
||||
@@ -443,11 +632,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 expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await shrinkVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
await panVisibleViewport(page);
|
||||
await expectFullscreenInsideVisibleViewport(page);
|
||||
await expectComposerFullscreenFitsVisibleViewport(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -2,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 { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
@@ -444,3 +444,25 @@ 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,7 +1,18 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
expectComposerFullscreenControls,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalLaunchSelected,
|
||||
@@ -145,3 +156,117 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -61,6 +62,10 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionPreview(page, {
|
||||
option: "Behind feature flag",
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(12);
|
||||
return rows.evaluateAll(
|
||||
(elements, prefix) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
|
||||
`sidebar-workspace-row-${getServerId()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
async function navigateWorkspace(
|
||||
page: Page,
|
||||
direction: "next" | "previous",
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
|
||||
}
|
||||
|
||||
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "relative-navigation-",
|
||||
title: "Workspace 01",
|
||||
});
|
||||
try {
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
title: `Workspace ${String(index).padStart(2, "0")}`,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await navigateWorkspace(page, "next", order[9]);
|
||||
await navigateWorkspace(page, "previous", order[8]);
|
||||
});
|
||||
|
||||
await test.step("wrapping uses the true first and last visible workspace", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await navigateWorkspace(page, "previous", order[11]);
|
||||
await navigateWorkspace(page, "next", order[0]);
|
||||
});
|
||||
|
||||
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Native question option preview
|
||||
|
||||
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"rollout\"" 20000
|
||||
press "label=\"rollout\""
|
||||
wait "id=\"question-form-option-preview\"" 10000
|
||||
gesture pan 570 1120 0 -440 600
|
||||
is visible "text=\"// LAST PREVIEW LINE\""
|
||||
close
|
||||
@@ -355,6 +355,12 @@ 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,
|
||||
@@ -390,6 +396,33 @@ 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();
|
||||
}
|
||||
@@ -405,6 +438,22 @@ 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")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function openCustomModels(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
|
||||
await page
|
||||
.getByTestId("add-custom-model-sheet")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Download, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
@@ -39,3 +39,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,15 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionPreview(
|
||||
page: Page,
|
||||
input: { option: string; preview: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
questionOption(page, input.option).getByTestId("question-form-option-preview"),
|
||||
).toContainText(input.preview);
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
|
||||
@@ -49,31 +49,37 @@ export async function seedParentWithSubagent(
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.childTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
},
|
||||
});
|
||||
const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
title: input.parentTitle,
|
||||
},
|
||||
child: {
|
||||
id: child.id,
|
||||
title: input.childTitle,
|
||||
},
|
||||
child,
|
||||
workspaceId: workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */
|
||||
export async function seedSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string; title: string }> {
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: input.parentId,
|
||||
},
|
||||
});
|
||||
return { id: child.id, title: input.title };
|
||||
}
|
||||
|
||||
export async function seedParentWithCrossWorkspaceSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** On compact the track opens as a sheet, which closes once a row is chosen. */
|
||||
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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,4 +29,15 @@ describe("formatThinkingOptionLabel", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
|
||||
});
|
||||
|
||||
it("preserves provider-supplied xhigh labels with extra detail", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (56 | 8m)" })).toBe(
|
||||
"X high (56 | 8m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (45 | 6m)" })).toBe(
|
||||
"X high (45 | 6m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh" })).toBe("Extra high");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "Extra high" })).toBe("Extra high");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,9 @@ export function formatAgentModeLabel(mode: ControlLabelInput): string {
|
||||
|
||||
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
|
||||
const rawLabel = (option.label ?? option.id).trim();
|
||||
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
|
||||
if (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
if (compactLabel === "xhigh" || compactLabel === "extrahigh") {
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -549,6 +550,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
<WorkspaceLayoutModePublisher />
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
@@ -663,7 +665,7 @@ function MobileGestureWrapper({
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill}>
|
||||
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
|
||||
{children}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
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<{
|
||||
@@ -23,16 +32,28 @@ 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>
|
||||
<NewWorkspaceScreen
|
||||
key={screenKey}
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
<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>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentProps, 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 } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
@@ -223,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
|
||||
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const combinedStyle = useMemo(
|
||||
() => [
|
||||
@@ -239,6 +240,39 @@ 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.
|
||||
*
|
||||
@@ -449,7 +483,6 @@ export function AdaptiveModalSheet({
|
||||
bodyStyle,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -481,10 +514,6 @@ 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
|
||||
@@ -600,7 +629,7 @@ export function AdaptiveModalSheet({
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
handleComponent={SheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
|
||||
@@ -35,6 +35,7 @@ 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";
|
||||
@@ -805,21 +806,18 @@ function DesktopSidebar({
|
||||
|
||||
function WorkspacesSectionHeader() {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -850,29 +848,6 @@ 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,6 +3,71 @@ 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,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
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;
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
@@ -259,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
if (
|
||||
isOpenTag(tokens[index], "details") ||
|
||||
findMixedHeadingClose(tokens, index) !== null ||
|
||||
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} numberOfLines={1}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -147,4 +147,29 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps option previews so options can be compared", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which layout?",
|
||||
header: "Layout",
|
||||
options: [
|
||||
{
|
||||
label: "Tabs",
|
||||
description: "One tab per section",
|
||||
preview: "<Tabs>\n <Tab />\n</Tabs>",
|
||||
},
|
||||
{ label: "Single page" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(questions?.[0]?.options).toEqual([
|
||||
{ label: "Tabs", description: "One tab per section", preview: "<Tabs>\n <Tab />\n</Tabs>" },
|
||||
{ label: "Single page", description: undefined, preview: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -46,6 +47,7 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
preview: readOptionalString(o, "preview"),
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -29,6 +30,9 @@ interface QuestionFormCardProps {
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
// Native previews are uncapped: inside the option's Pressable, Android never hands a
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
@@ -133,6 +137,20 @@ function QuestionOptionRow({
|
||||
{option.description ? (
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
// Long lines wrap, as in Claude Code, since most previews are prose.
|
||||
<ScrollableCodeSurface
|
||||
tone="surface0"
|
||||
maxHeight={OPTION_PREVIEW_MAX_HEIGHT}
|
||||
horizontal={false}
|
||||
selectable={false}
|
||||
textStyle={styles.optionPreviewText}
|
||||
testID="question-form-option-preview"
|
||||
>
|
||||
{option.preview}
|
||||
</ScrollableCodeSurface>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -704,6 +722,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
},
|
||||
selectionControl: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { density, 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 { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
@@ -2114,6 +2114,7 @@ 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>>>(
|
||||
@@ -2413,7 +2414,12 @@ function ProjectModeList({
|
||||
<>
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
<SidebarSectionHeader
|
||||
title={t("sidebar.pinned.title")}
|
||||
collapsed={pinnedCollapsed}
|
||||
onToggle={togglePinnedCollapsed}
|
||||
testID="sidebar-pinned-section-header"
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<DraggableList
|
||||
@@ -2550,12 +2556,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
newWorkspaceGhostText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
newWorkspaceGhostTextHovered: {
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
@@ -2603,7 +2609,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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 { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import { densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
@@ -24,6 +24,9 @@ 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
|
||||
@@ -273,10 +276,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 shares the same `rowLineHeight`.
|
||||
// workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
|
||||
projectLeadingVisualSlot: {
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
height: LEADING_SLOT_HEIGHT,
|
||||
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, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { density, 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: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.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, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { density, 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: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import { density, 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 { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-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,6 +146,7 @@ export function SidebarStatusWorkspaceList({
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedWorkspaceGroupKeys,
|
||||
);
|
||||
@@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({
|
||||
<>
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
<SidebarSectionHeader
|
||||
title={t("sidebar.pinned.title")}
|
||||
collapsed={pinnedCollapsed}
|
||||
onToggle={togglePinnedCollapsed}
|
||||
testID="sidebar-pinned-section-header"
|
||||
/>
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
<DraggableList
|
||||
@@ -1099,7 +1105,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusGroupTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.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 { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import { 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,6 +453,9 @@ 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",
|
||||
@@ -481,7 +484,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
height: TITLE_LINE_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
@@ -513,9 +516,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: densityFontSize(theme, "base"),
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: "400",
|
||||
lineHeight: density.rowLineHeight,
|
||||
lineHeight: TITLE_LINE_HEIGHT,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
|
||||
@@ -154,3 +154,4 @@ export function useIsolatedBottomSheetVisibility({
|
||||
}
|
||||
|
||||
export { SheetVisibleFrame } from "./visible-frame";
|
||||
export { systemBackPress } from "./back-press";
|
||||
|
||||
@@ -104,6 +104,13 @@ 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,6 +77,10 @@ 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,6 +8,7 @@ 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,8 +125,7 @@ function buildCreateAgentOptions({
|
||||
provider,
|
||||
}: {
|
||||
composerState: {
|
||||
modeOptions: { id: string }[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
};
|
||||
@@ -137,20 +136,11 @@ 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,
|
||||
...(reconciledMode !== "" ? { modeId: reconciledMode } : {}),
|
||||
...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -8,22 +9,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,
|
||||
resolveInlineComposerCapacity,
|
||||
resolveComposerSlotCapacity,
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
} from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
@@ -55,7 +56,6 @@ function ComposerViewport({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const capacity = useDerivedValue(() => sizing.value?.capacity);
|
||||
const measureViewport = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
measuredHeight.value = event.nativeEvent.layout.height;
|
||||
@@ -66,20 +66,24 @@ function ComposerViewport({
|
||||
|
||||
return (
|
||||
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
|
||||
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
|
||||
<ViewportSizing.Provider value={sizing}>{children}</ViewportSizing.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bound the complete composer (including its controls), not just the text input. */
|
||||
function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
function ComposerViewportContent({
|
||||
style,
|
||||
fullscreen = false,
|
||||
...props
|
||||
}: ViewProps & { fullscreen?: boolean }) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const capacity = useContext(ViewportCapacity);
|
||||
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||
const sizing = useContext(ViewportSizing);
|
||||
if (!sizing) 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: resolveInlineComposerCapacity(capacity.value, compact),
|
||||
maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -108,37 +112,71 @@ 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 ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
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,
|
||||
},
|
||||
];
|
||||
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={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
style={
|
||||
fullscreen
|
||||
? dockStyles.viewport
|
||||
: [dockStyles.centeredViewport, { paddingBottom: bottomInset }]
|
||||
}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
onLayout={centeredClearance.measureViewport}
|
||||
>
|
||||
<KeyboardTranslateView
|
||||
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||
style={
|
||||
fullscreen ? dockStyles.surface : [dockStyles.centered, { maxWidth: contentMaxWidth }]
|
||||
}
|
||||
enabled={!fullscreen}
|
||||
bottomClearance={centeredClearance.value}
|
||||
onLayout={centeredClearance.measureForm}
|
||||
>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.composer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<ScrollView
|
||||
style={fullscreen ? dockStyles.hiddenSetup : dockStyles.setup}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
<ComposerViewportContent style={dockStyles.centeredComposer}>
|
||||
{composer}
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
@@ -148,7 +186,7 @@ export function ComposerDock({
|
||||
}
|
||||
return (
|
||||
<ComposerViewport style={dockStyles.viewport}>
|
||||
<KeyboardTranslateView style={dockStyles.surface}>
|
||||
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
|
||||
<View
|
||||
testID="composer-dock-content"
|
||||
collapsable={false}
|
||||
@@ -162,8 +200,19 @@ export function ComposerDock({
|
||||
{content}
|
||||
</View>
|
||||
</View>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
|
||||
<ComposerViewportContent
|
||||
style={fullscreen ? dockStyles.fullscreenSlot : dockStyles.composer}
|
||||
fullscreen={fullscreen}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
fullscreen
|
||||
? fullscreenSurface
|
||||
: [dockStyles.composer, { paddingBottom: insets.bottom }]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
</View>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
@@ -178,9 +227,16 @@ 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,48 +1,69 @@
|
||||
export { ComposerDockBackground } from "./internal/background";
|
||||
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { useState, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useState, useCallback, useMemo, useRef, 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 [availableHeight, setAvailableHeight] = useState<number>();
|
||||
const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>();
|
||||
const surfaceRef = useRef<View>(null);
|
||||
const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize);
|
||||
const constraint = useMemo(
|
||||
() => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }),
|
||||
[availableHeight, compact],
|
||||
() => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }),
|
||||
[surfaceSize?.height, compact],
|
||||
);
|
||||
const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
if (height > 0) setAvailableHeight(height);
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
if (height > 0) setSurfaceSize({ width, height });
|
||||
}, []);
|
||||
if (centered) {
|
||||
return (
|
||||
<View style={styles.centered} onLayout={measureViewport}>
|
||||
<View ref={surfaceRef} style={styles.centered} onLayout={measureViewport}>
|
||||
<View style={styles.form}>
|
||||
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
</ScrollView>
|
||||
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
|
||||
<View
|
||||
style={
|
||||
fullscreen
|
||||
? [styles.fullscreenComposer, fullscreenFrame]
|
||||
: [styles.centeredComposer, constraint]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
</View>
|
||||
{overlay}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.surface} onLayout={measureViewport}>
|
||||
<View ref={surfaceRef} style={styles.surface} onLayout={measureViewport}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
<View style={[styles.composer, constraint]}>{composer}</View>
|
||||
<View
|
||||
style={
|
||||
fullscreen ? [styles.fullscreenComposer, fullscreenFrame] : [styles.composer, constraint]
|
||||
}
|
||||
>
|
||||
{composer}
|
||||
</View>
|
||||
{overlay}
|
||||
</View>
|
||||
);
|
||||
@@ -52,6 +73,10 @@ 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,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCenteredClearance,
|
||||
resolveComposerSlotCapacity,
|
||||
resolveInlineComposerCapacity,
|
||||
resolveComposerCapacity,
|
||||
updateComposerCapacity,
|
||||
@@ -19,6 +20,20 @@ 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,6 +10,8 @@ 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 {
|
||||
@@ -48,6 +50,7 @@ export function updateComposerCapacity(
|
||||
return {
|
||||
keyboardReserve,
|
||||
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
|
||||
viewportHeight: input.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,3 +63,19 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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 },
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
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,6 +210,8 @@ 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");
|
||||
@@ -223,6 +225,7 @@ 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,6 +17,7 @@ import {
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -46,6 +47,7 @@ interface UseAgentInputDraftInput {
|
||||
|
||||
type DraftComposerState = UseAgentFormStateResult & {
|
||||
workingDir: string;
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -121,10 +123,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}));
|
||||
|
||||
const publishTextReplacement = useCallback(
|
||||
(nextText: string) => {
|
||||
(kind: "hydrated" | "replace", nextText: string) => {
|
||||
textReplacementRevisionRef.current += 1;
|
||||
setTextReplacement({
|
||||
kind: "replace",
|
||||
kind,
|
||||
key: `${draftKey}:${textReplacementRevisionRef.current}`,
|
||||
text: nextText,
|
||||
});
|
||||
@@ -174,7 +176,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
(nextText: string) => {
|
||||
textPublication.cancel();
|
||||
useDraftStore.getState().editDraftText({ draftKey, text: nextText });
|
||||
publishTextReplacement(nextText);
|
||||
publishTextReplacement("replace", nextText);
|
||||
},
|
||||
[draftKey, publishTextReplacement, textPublication],
|
||||
);
|
||||
@@ -227,7 +229,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
|
||||
publishTextReplacement(hydratedText);
|
||||
publishTextReplacement("hydrated", hydratedText);
|
||||
setHydratedDraftKey(draftKey);
|
||||
}
|
||||
})();
|
||||
@@ -256,6 +258,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveModeId = useMemo(
|
||||
() => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading),
|
||||
[formState.isModelLoading, providerSelection],
|
||||
);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() => resolveEffectiveComposerModelId(providerSelection),
|
||||
[providerSelection],
|
||||
@@ -315,6 +322,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return {
|
||||
...formState,
|
||||
workingDir,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
@@ -329,6 +337,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}, [
|
||||
commandDraft,
|
||||
composerOptions,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatures,
|
||||
@@ -355,6 +364,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandTarget,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -86,46 +87,30 @@ 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;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
}): { modeId: string } | Record<string, never> {
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
if (autoSubmitConfig?.modeId) {
|
||||
return { modeId: autoSubmitConfig.modeId };
|
||||
}
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return { modeId: reconciled };
|
||||
if (effectiveModeId !== "") {
|
||||
return { modeId: effectiveModeId };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveDraftModeId(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
}): string | null {
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
if (autoSubmitConfig?.modeId !== undefined) {
|
||||
return autoSubmitConfig.modeId;
|
||||
}
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return reconciled;
|
||||
if (effectiveModeId !== "") {
|
||||
return effectiveModeId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -143,8 +128,7 @@ async function submitDraftCreateRequest(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
composerState: {
|
||||
selectedProvider: string | null;
|
||||
selectedMode: string;
|
||||
modeOptions: readonly { id: string }[];
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -177,8 +161,7 @@ async function submitDraftCreateRequest(input: {
|
||||
}
|
||||
const modeIdOverride = resolveDraftModeIdOverride({
|
||||
autoSubmitConfig,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
});
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider,
|
||||
@@ -219,8 +202,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
composerState: {
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
modeOptions: readonly { id: string }[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
selectedProvider: string | null;
|
||||
agentControls: { features?: Agent["features"] };
|
||||
};
|
||||
@@ -234,8 +216,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
|
||||
const modeId = resolveDraftModeId({
|
||||
autoSubmitConfig,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
});
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
@@ -405,6 +386,8 @@ 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 },
|
||||
@@ -648,10 +631,10 @@ export function WorkspaceDraftAgentTab({
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ComposerDock>
|
||||
<ComposerDock fullscreen={isComposerFullscreen}>
|
||||
{dockContent}
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
{importPillPress && !isComposerFullscreen ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
@@ -675,6 +658,7 @@ export function WorkspaceDraftAgentTab({
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
@@ -692,6 +676,8 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
flexShrink: 1,
|
||||
// Content-sized inline; fills the dock's fullscreen slot.
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,52 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ComposerEditingSession } from "./index";
|
||||
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
|
||||
|
||||
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", () => {
|
||||
describe("composer editing session", () => {
|
||||
it("publishes fullscreen changes once per transition", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
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);
|
||||
const changes: boolean[] = [];
|
||||
session.subscribe(() => changes.push(session.getFullscreen()));
|
||||
session.setFullscreen(true);
|
||||
session.setFullscreen(true);
|
||||
session.setFullscreen(false);
|
||||
expect(changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("keeps local edits when the last applied replacement is still current", () => {
|
||||
it("hands the retained caret and focus intent to exactly one later mount", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
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 },
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
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,5 +1,6 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -7,62 +8,72 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { ComposerInputMode } from "@/composer/input-mode";
|
||||
|
||||
export interface ComposerEditingHandoff {
|
||||
snapshot: { text: string; selection: { start: number; end: number } };
|
||||
focused: boolean;
|
||||
replacementKey: string;
|
||||
export interface ComposerSelection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
|
||||
/** 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. */
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class ComposerEditingSession {
|
||||
private fullscreen = false;
|
||||
private handoff: ComposerEditingHandoff | null = null;
|
||||
private retained: ComposerRetainedEditor | 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);
|
||||
};
|
||||
};
|
||||
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
|
||||
this.captureForRemount(handoff);
|
||||
setFullscreen(fullscreen: boolean) {
|
||||
if (fullscreen === this.fullscreen) return;
|
||||
this.fullscreen = fullscreen;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
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;
|
||||
this.onChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
class ComposerEditingSessions {
|
||||
private workspaces = new Map<
|
||||
string,
|
||||
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
|
||||
>();
|
||||
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);
|
||||
};
|
||||
};
|
||||
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
|
||||
let workspace = this.workspaces.get(workspaceKey);
|
||||
if (!workspace) {
|
||||
@@ -76,17 +87,31 @@ class ComposerEditingSessions {
|
||||
}
|
||||
let session = tab.sessions.get(purpose);
|
||||
if (!session) {
|
||||
session = new ComposerEditingSession();
|
||||
session = new ComposerEditingSession(this.notify);
|
||||
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);
|
||||
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
|
||||
let changed = false;
|
||||
for (const tabId of workspace.keys()) {
|
||||
if (retained.has(tabId)) continue;
|
||||
workspace.delete(tabId);
|
||||
changed = true;
|
||||
}
|
||||
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
|
||||
if (changed) this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,3 +165,16 @@ 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,4 +1,8 @@
|
||||
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
|
||||
import {
|
||||
useComposerEditingSession,
|
||||
useComposerFullscreen,
|
||||
type ComposerEditingSession,
|
||||
} from "./editing-session";
|
||||
import type { ComposerTextSource } from "./text-source";
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { useStore } from "zustand";
|
||||
@@ -27,6 +31,7 @@ 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 {
|
||||
@@ -967,6 +972,12 @@ 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[] = [];
|
||||
@@ -1182,8 +1193,14 @@ 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}>
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused && !coveredByOverlayPanel}>
|
||||
<RenderProfile id="ComposerContent">
|
||||
<ComposerContent {...props} />
|
||||
</RenderProfile>
|
||||
@@ -1234,6 +1251,7 @@ function ComposerContentImpl({
|
||||
inputWrapperStyle,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
inputMode = "chat",
|
||||
editingSession: providedEditingSession,
|
||||
readOnly = false,
|
||||
submitLabel,
|
||||
placeholder,
|
||||
@@ -1339,7 +1357,7 @@ function ComposerContentImpl({
|
||||
const nextPendingFileId = useRef(0);
|
||||
const isUploadingFile = pendingFiles.length > 0;
|
||||
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
|
||||
const editingSession = useComposerEditingSession(inputMode);
|
||||
const editingSession = useComposerEditingSession(inputMode, providedEditingSession);
|
||||
const isFullscreen = useComposerFullscreen(editingSession);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
||||
@@ -2250,9 +2268,23 @@ 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],
|
||||
[isComposerLocked],
|
||||
() => [
|
||||
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],
|
||||
);
|
||||
|
||||
const attachmentTray = useMemo(
|
||||
@@ -2363,15 +2395,19 @@ function ComposerContentImpl({
|
||||
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
|
||||
isMessageInputFocused={isMessageInputFocused}
|
||||
/>
|
||||
<View style={animatedStaticStyles.container}>
|
||||
<View
|
||||
style={
|
||||
isFullscreen ? animatedStaticStyles.fullscreenContainer : animatedStaticStyles.container
|
||||
}
|
||||
>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
<View style={inputAreaContentStyle}>
|
||||
{isFullscreen ? null : queueList}
|
||||
{!isFullscreen && sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<View ref={messageInputContainerRef} style={messageInputContainerStyle}>
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
@@ -2475,6 +2511,12 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
fullscreenContainer: {
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
@@ -2496,6 +2538,18 @@ 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%",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -1,77 +0,0 @@
|
||||
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,6 +84,7 @@ import {
|
||||
computeCanStartDictation,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveRetainedSelection,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
@@ -676,13 +677,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.textInputScrollWrapper,
|
||||
props.fullscreen && styles.fullscreenTextSurface,
|
||||
props.compact && !props.fullscreen && styles.compactTextSurface,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.textInputScrollWrapper, props.fullscreen && styles.fullscreenTextSurface]}>
|
||||
{!isWeb && props.compact && !props.fullscreen ? (
|
||||
<Text
|
||||
accessible={false}
|
||||
@@ -1040,11 +1035,8 @@ function getComposerInputSnapshot(
|
||||
current: ComposerTextInputHandle | null,
|
||||
fallbackText: string,
|
||||
fallbackSelection: ComposerInputSnapshot["selection"],
|
||||
mountedTextArea?: TextAreaHandle | null,
|
||||
): ComposerInputSnapshot {
|
||||
// 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 textArea = getWebTextAreaImpl(current);
|
||||
const text = textArea?.value ?? current?.getText() ?? fallbackText;
|
||||
const start = textArea?.selectionStart ?? fallbackSelection.start;
|
||||
const end = textArea?.selectionEnd ?? fallbackSelection.end;
|
||||
@@ -1269,18 +1261,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const inputWrapperRef = useRef<View | null>(null);
|
||||
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
|
||||
const isInputFocusedRef = useRef(false);
|
||||
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 focusIntentRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
const selectionRef = useRef({ 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;
|
||||
@@ -1289,43 +1276,49 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
setHasLiveText(nextHasLiveText);
|
||||
}, []);
|
||||
|
||||
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],
|
||||
);
|
||||
// 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);
|
||||
}, []);
|
||||
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,
|
||||
@@ -1340,29 +1333,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
|
||||
const resetComposerHeight = measuredComposerHeight?.reset;
|
||||
|
||||
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;
|
||||
},
|
||||
[editingSession],
|
||||
);
|
||||
const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]);
|
||||
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) => onHeightChange?.(event.nativeEvent.layout.height),
|
||||
[onHeightChange],
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height);
|
||||
},
|
||||
[isFullscreen, onHeightChange],
|
||||
);
|
||||
|
||||
const replaceText = useCallback(
|
||||
@@ -1427,8 +1406,15 @@ 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);
|
||||
}
|
||||
@@ -1696,17 +1682,6 @@ 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,
|
||||
@@ -1832,6 +1807,8 @@ 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);
|
||||
@@ -1888,8 +1865,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
styles.textInput,
|
||||
mode.isMonospace && styles.textInputMonospace,
|
||||
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
|
||||
isCompact && !isFullscreen && styles.compactTextInput,
|
||||
],
|
||||
[composerHeightStyle, mode.isMonospace, isFullscreen],
|
||||
[composerHeightStyle, mode.isMonospace, isFullscreen, isCompact],
|
||||
);
|
||||
// Static content has no textarea to mirror, so it grows with its own text
|
||||
// instead of the measured input height.
|
||||
@@ -1897,13 +1875,22 @@ 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,
|
||||
isSendButtonDisabled && styles.buttonDisabled,
|
||||
isPresentedSendDisabled && styles.buttonDisabled,
|
||||
],
|
||||
[isSendButtonDisabled, submitLabel],
|
||||
[isPresentedSendDisabled, submitLabel],
|
||||
);
|
||||
const overlayContainerStyle = useMemo(
|
||||
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
|
||||
@@ -1942,14 +1929,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton={canPressLoadingButton}
|
||||
onSubmitLoadingPress={onSubmitLoadingPress}
|
||||
onDefaultSendAction={handleDefaultSendAction}
|
||||
isSendButtonDisabled={resolvePresentationSendDisabled({
|
||||
fullscreen: isFullscreen,
|
||||
disabled: isSendButtonDisabled,
|
||||
hasText: hasLiveText,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmpty: allowEmptySubmit,
|
||||
})}
|
||||
isSendButtonDisabled={isPresentedSendDisabled}
|
||||
submitAccessibilityLabel={submitAccessibilityLabel}
|
||||
sendButtonCombinedStyle={sendButtonCombinedStyle}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
@@ -1968,11 +1948,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton,
|
||||
onSubmitLoadingPress,
|
||||
handleDefaultSendAction,
|
||||
isSendButtonDisabled,
|
||||
hasLiveText,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
isPresentedSendDisabled,
|
||||
submitAccessibilityLabel,
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
@@ -2008,9 +1984,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onBlur={handleInputBlur}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
scrollEnabled={isComposerScrollEnabled}
|
||||
autoFocus={
|
||||
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
|
||||
}
|
||||
autoFocus={false}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onPasteImages={onPasteImages}
|
||||
@@ -2029,8 +2003,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[
|
||||
readOnly,
|
||||
textReplacement,
|
||||
editingSession,
|
||||
isActiveComposer,
|
||||
assignTextInput,
|
||||
textInputStyle,
|
||||
readOnlyTextStyle,
|
||||
@@ -2162,11 +2134,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
handleRealtimeVoiceStop,
|
||||
],
|
||||
);
|
||||
const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]);
|
||||
return (
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={styles.container}
|
||||
style={isFullscreen ? styles.fullscreenContainer : styles.container}
|
||||
testID="message-input-root"
|
||||
onLayout={handleComposerLayout}
|
||||
>
|
||||
@@ -2177,8 +2148,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
/>
|
||||
<ComposerInputPresentation
|
||||
fullscreen={isFullscreen}
|
||||
iconSize={buttonIconSize}
|
||||
active={isActiveComposer}
|
||||
sendIconSize={buttonIconSize}
|
||||
surface={textSurface}
|
||||
toolbar={toolbar}
|
||||
send={sendButton}
|
||||
@@ -2207,6 +2177,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
fullscreenContainer: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
inputWrapper: {
|
||||
flexShrink: 1,
|
||||
flexDirection: "column",
|
||||
@@ -2241,14 +2215,18 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
inputWrapperReadOnly: {
|
||||
borderStyle: "dotted",
|
||||
},
|
||||
compactTextSurface: { paddingRight: 36 },
|
||||
// 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 },
|
||||
fullscreenTextSurface: { flex: 1, minHeight: 0 },
|
||||
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
|
||||
lineMeasurement: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 36,
|
||||
right: 0,
|
||||
opacity: 0,
|
||||
maxHeight: undefined,
|
||||
minHeight: undefined,
|
||||
|
||||
@@ -3,19 +3,31 @@ 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 { ComposerFullscreenHost } from "./fullscreen-host";
|
||||
import { useComposerFullscreenExit } from "./fullscreen-exit";
|
||||
import { resolveGlyphRailOffset } from "./state";
|
||||
|
||||
const CONTROL_SIZE = 28;
|
||||
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
|
||||
|
||||
function keepEditorFocused(event: import("react-native").PointerEvent) {
|
||||
if (isWeb) event.preventDefault();
|
||||
}
|
||||
|
||||
/** Both presentations receive the same editing surface and send capability. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ComposerInputPresentation({
|
||||
fullscreen,
|
||||
iconSize,
|
||||
active,
|
||||
sendIconSize,
|
||||
surface,
|
||||
toolbar,
|
||||
send,
|
||||
@@ -30,8 +42,7 @@ export function ComposerInputPresentation({
|
||||
overlay,
|
||||
}: {
|
||||
fullscreen: boolean;
|
||||
iconSize: number;
|
||||
active: boolean;
|
||||
sendIconSize: number;
|
||||
surface: ReactNode;
|
||||
toolbar: ReactNode;
|
||||
send: ReactNode;
|
||||
@@ -46,55 +57,33 @@ export function ComposerInputPresentation({
|
||||
overlay: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// 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;
|
||||
// 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,
|
||||
});
|
||||
const maximizeIcon = useCallback(
|
||||
(color: string) => <Maximize2 color={color} size={iconSize} />,
|
||||
[iconSize],
|
||||
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
|
||||
[],
|
||||
);
|
||||
const minimizeIcon = useCallback(
|
||||
(color: string) => <Minimize2 color={color} size={iconSize} />,
|
||||
[iconSize],
|
||||
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
|
||||
[],
|
||||
);
|
||||
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 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
accessibilityLabel={t("composer.input.fullscreen")}
|
||||
leftIcon={maximizeIcon}
|
||||
onPointerDown={keepEditorFocused}
|
||||
onPress={onEnter}
|
||||
style={enterStyle}
|
||||
hitSlop={8}
|
||||
>
|
||||
{null}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
{toolbar}
|
||||
</View>
|
||||
{overlay}
|
||||
</>
|
||||
);
|
||||
// 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 (
|
||||
<ComposerFullscreenHost onExit={onExit} active={active}>
|
||||
<View style={styles.fullscreen} testID="composer-fullscreen">
|
||||
<View
|
||||
ref={exitScopeRef}
|
||||
style={fullscreen ? styles.fullscreen : styles.inline}
|
||||
testID={fullscreen ? "composer-fullscreen" : undefined}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<View style={styles.exitRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -109,28 +98,60 @@ export function ComposerInputPresentation({
|
||||
{null}
|
||||
</Button>
|
||||
</View>
|
||||
{surface}
|
||||
{error}
|
||||
<View style={styles.sendRow}>{send}</View>
|
||||
</View>
|
||||
</ComposerFullscreenHost>
|
||||
) : null}
|
||||
<ComposerFullscreenBody fullscreen={fullscreen}>
|
||||
<View
|
||||
ref={inputWrapperRef}
|
||||
style={fullscreen ? styles.fullscreenInputWrapper : inputWrapperStyle}
|
||||
pointerEvents={inputPointerEvents}
|
||||
>
|
||||
{fullscreen ? null : attachmentSlot}
|
||||
<View style={fullscreen ? styles.fullscreenTextSurface : styles.inlineTextSurface}>
|
||||
{surface}
|
||||
{showFullscreenButton && !fullscreen ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
accessibilityLabel={t("composer.input.fullscreen")}
|
||||
leftIcon={maximizeIcon}
|
||||
onPointerDown={keepEditorFocused}
|
||||
onPress={onEnter}
|
||||
style={enterStyle}
|
||||
hitSlop={8}
|
||||
>
|
||||
{null}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
{fullscreen ? null : toolbar}
|
||||
</View>
|
||||
</ComposerFullscreenBody>
|
||||
{fullscreen ? (
|
||||
<ComposerFullscreenFooter style={styles.footer}>
|
||||
{error}
|
||||
<View style={styles.sendRow}>{send}</View>
|
||||
</ComposerFullscreenFooter>
|
||||
) : null}
|
||||
{overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
inline: { flexShrink: 1, position: "relative" },
|
||||
inlineTextSurface: { flexShrink: 1, position: "relative" },
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: CONTROL_SIZE,
|
||||
height: CONTROL_SIZE,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
fullscreenButton: {
|
||||
enterButton: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: CONTROL_SIZE,
|
||||
height: CONTROL_SIZE,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
@@ -141,6 +162,10 @@ 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 },
|
||||
sendRow: { flexShrink: 0, alignItems: "flex-end" },
|
||||
// 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" },
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
resolveActiveSendBehavior,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveGlyphRailOffset,
|
||||
resolveRetainedSelection,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
@@ -333,10 +335,30 @@ describe("composer surface text", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the live handoff when its replacement has already been applied", () => {
|
||||
it("keeps the live buffer when the initial publication is already applied", () => {
|
||||
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
|
||||
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
|
||||
"Live remount handoff",
|
||||
expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe(
|
||||
"Live edited draft",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,6 +193,24 @@ 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,
|
||||
@@ -200,3 +218,13 @@ 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,8 +9,12 @@ 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" | "replace";
|
||||
kind: "initial" | "hydrated" | "replace";
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -165,4 +165,7 @@ const FRAME_STYLE = {
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } 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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
|
||||
@@ -12,8 +12,9 @@ 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 host token + download-store
|
||||
* pipeline instead of duplicating the plumbing.
|
||||
* 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.
|
||||
*/
|
||||
export function useFileDownload({
|
||||
serverId,
|
||||
@@ -30,7 +31,7 @@ export function useFileDownload({
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const { requestFileDownloadToken } = useFileExplorerActions({
|
||||
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
@@ -48,9 +49,18 @@ export function useFileDownload({
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
readFile: requestFileBytes,
|
||||
});
|
||||
},
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
[
|
||||
daemonProfile,
|
||||
requestFileBytes,
|
||||
requestFileDownloadToken,
|
||||
serverId,
|
||||
startDownload,
|
||||
workspaceScopeId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,21 @@ 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) {
|
||||
@@ -342,6 +357,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
requestFileBytes,
|
||||
createEntry,
|
||||
renameEntry,
|
||||
duplicateEntry,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
parseClampedFontSize,
|
||||
parseTerminalScrollbackLines,
|
||||
resolveContentMaxWidth,
|
||||
sanitizeFontFamily,
|
||||
saveAppSettings,
|
||||
type SettingsDeps,
|
||||
} from "./storage";
|
||||
@@ -1094,3 +1095,34 @@ 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,7 +522,24 @@ 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
|
||||
}
|
||||
return trimmed; // quotes/commas are legit in stacks
|
||||
// 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;
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
|
||||
|
||||
@@ -613,6 +613,9 @@ export const ar: TranslationResources = {
|
||||
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
||||
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "فتح كعلامة تبويب",
|
||||
},
|
||||
tabs: {
|
||||
loading: "تحميل...",
|
||||
modified: "تغييرات غير محفوظة",
|
||||
|
||||
@@ -610,6 +610,9 @@ 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,6 +619,9 @@ 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,6 +618,9 @@ 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,6 +619,9 @@ export const ja: TranslationResources = {
|
||||
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
||||
unableToSubscribe: "ターミナルに接続できません",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "タブで開く",
|
||||
},
|
||||
tabs: {
|
||||
loading: "読み込み中...",
|
||||
modified: "未保存の変更",
|
||||
|
||||
@@ -614,6 +614,9 @@ export const ko: TranslationResources = {
|
||||
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
|
||||
unableToSubscribe: "터미널을 구독할 수 없습니다",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "탭으로 열기",
|
||||
},
|
||||
tabs: {
|
||||
loading: "불러오는 중...",
|
||||
modified: "저장되지 않은 변경사항",
|
||||
|
||||
@@ -618,6 +618,9 @@ export const ptBR: TranslationResources = {
|
||||
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
|
||||
unableToSubscribe: "Não foi possível assinar o terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Abrir como aba",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Carregando...",
|
||||
modified: "Alterações não salvas",
|
||||
|
||||
@@ -620,6 +620,9 @@ export const ru: TranslationResources = {
|
||||
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
|
||||
unableToSubscribe: "Не удалось подключиться к терминалу",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Открыть во вкладке",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Загрузка...",
|
||||
modified: "Несохранённые изменения",
|
||||
|
||||
@@ -613,6 +613,9 @@ export const zhCN: TranslationResources = {
|
||||
updateHost: "请更新主机以使用原生终端渲染器。",
|
||||
unableToSubscribe: "无法订阅 Terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "作为标签页打开",
|
||||
},
|
||||
tabs: {
|
||||
loading: "正在加载...",
|
||||
modified: "未保存的更改",
|
||||
|
||||
@@ -27,6 +27,11 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { RetainedChatContent } from "./retained-chat-content";
|
||||
import { Composer } from "@/composer";
|
||||
import {
|
||||
useComposerEditingSession,
|
||||
useComposerFullscreen,
|
||||
type ComposerEditingSession,
|
||||
} from "@/composer/editing-session";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import {
|
||||
resolveComposerTrackControlClearance,
|
||||
@@ -1153,6 +1158,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
|
||||
const showsComposer = usePaneContext().host !== "sheet";
|
||||
// The dock lays out fullscreen for the same session the composer edits in.
|
||||
const composerEditingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
@@ -1216,7 +1226,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const composerSection = (
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
@@ -1227,13 +1237,14 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
cwd={cwd}
|
||||
isSubmitLoading={false}
|
||||
agentInputDraft={agentInputDraft}
|
||||
editingSession={composerEditingSession}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
);
|
||||
) : null;
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||
@@ -1290,7 +1301,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
);
|
||||
|
||||
const dock = (
|
||||
<ChatSurface disabled={isArchivingCurrentAgent}>
|
||||
<ChatSurface disabled={isArchivingCurrentAgent} fullscreen={isComposerFullscreen}>
|
||||
{dockContent}
|
||||
{composerSection}
|
||||
{dockOverlay}
|
||||
@@ -1321,13 +1332,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
function ChatSurface({
|
||||
children,
|
||||
disabled,
|
||||
fullscreen,
|
||||
}: {
|
||||
children: [ReactNode, ReactNode, ReactNode];
|
||||
disabled: boolean;
|
||||
fullscreen: boolean;
|
||||
}) {
|
||||
return (
|
||||
<FileDropZone style={styles.container} disabled={disabled}>
|
||||
<ComposerDock>{children}</ComposerDock>
|
||||
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
@@ -1471,6 +1484,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
editingSession,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onComposerHeightChange,
|
||||
@@ -1484,6 +1498,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
editingSession: ComposerEditingSession;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
@@ -1507,6 +1522,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
|
||||
cwd={cwd}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
agentInputDraft={agentInputDraft}
|
||||
editingSession={editingSession}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
@@ -1522,6 +1538,7 @@ function ActiveAgentComposer({
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
editingSession,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onComposerHeightChange,
|
||||
@@ -1533,6 +1550,7 @@ function ActiveAgentComposer({
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
editingSession: ComposerEditingSession;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
@@ -1629,6 +1647,7 @@ function ActiveAgentComposer({
|
||||
onChangeAttachments={agentInputDraft.setAttachments}
|
||||
cwd={cwd}
|
||||
clearDraft={agentInputDraft.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus
|
||||
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
@@ -1721,6 +1740,8 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
flexShrink: 1,
|
||||
// Content-sized inline; fills the dock's fullscreen slot.
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import { AgentTaskList } from "@/composer/task-list";
|
||||
import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
|
||||
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
|
||||
@@ -49,10 +49,9 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
onArchiveFinished: () => void;
|
||||
hasPluginComposerPills: boolean;
|
||||
}): ReactElement | null {
|
||||
const { tabId, openTab } = usePaneContext();
|
||||
const { tabId } = usePaneContext();
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const canDetachSubagents = useSessionStore(
|
||||
@@ -60,45 +59,36 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
);
|
||||
const archiveSubagent = useArchiveSubagent({ serverId });
|
||||
const detachSubagent = useDetachSubagent({ serverId });
|
||||
const openSubagentTarget = useCallback(
|
||||
(target: WorkspaceTabTarget) => {
|
||||
openPreferredWorkspaceTarget({
|
||||
workspaceKey,
|
||||
target,
|
||||
source: "subagents",
|
||||
preferences: openInSidePane,
|
||||
parentTabId: tabId,
|
||||
});
|
||||
},
|
||||
[openInSidePane, tabId, workspaceKey],
|
||||
);
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
|
||||
// A subagent in another workspace lives in that workspace's tabs.
|
||||
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
return;
|
||||
}
|
||||
if (canSplit && workspaceKey) {
|
||||
openPreferredWorkspaceTarget({
|
||||
isCompact,
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: subagentId },
|
||||
source: "subagents",
|
||||
preferences: openInSidePane,
|
||||
parentTabId: tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
openSubagentTarget({ kind: "agent", agentId: subagentId });
|
||||
},
|
||||
[canSplit, isCompact, openInSidePane, serverId, tabId, workspaceId, workspaceKey],
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
if (canSplit && workspaceKey) {
|
||||
openPreferredWorkspaceTarget({
|
||||
isCompact,
|
||||
workspaceKey,
|
||||
target: { kind: "provider_subagent", parentAgentId, subagentId },
|
||||
source: "subagents",
|
||||
preferences: openInSidePane,
|
||||
parentTabId: tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
},
|
||||
[canSplit, isCompact, openInSidePane, openTab, tabId, workspaceKey],
|
||||
[openSubagentTarget],
|
||||
);
|
||||
const handleOpenChanges = useCallback(() => {
|
||||
if (!workspaceKey) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
export interface PanelInstanceIdentity {
|
||||
serverId: string;
|
||||
@@ -18,8 +19,20 @@ const listenersByPanel = new Map<string, Set<() => void>>();
|
||||
const allListeners = new Set<() => void>();
|
||||
let attributesRevision = 0;
|
||||
|
||||
function buildWorkspacePanelKey(workspaceKey: string, tabId: string): string {
|
||||
return `${workspaceKey}:${tabId}`;
|
||||
}
|
||||
|
||||
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
|
||||
return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`;
|
||||
return buildWorkspacePanelKey(buildWorkspaceTabPersistenceKey(identity) ?? "", identity.tabId);
|
||||
}
|
||||
|
||||
/** Looks a panel up by the workspace key the layout store uses. */
|
||||
export function getWorkspacePanelAttributes(
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
): PanelInstanceAttributes {
|
||||
return attributesByPanel.get(buildWorkspacePanelKey(workspaceKey, tabId)) ?? DEFAULT_ATTRIBUTES;
|
||||
}
|
||||
|
||||
export function getPanelInstanceAttributes(
|
||||
|
||||
@@ -18,6 +18,14 @@ describe("panel manifest", () => {
|
||||
expect(panelSupportsHost("setup", "explorer")).toBe(false);
|
||||
});
|
||||
|
||||
it("supports the sheet exactly for kinds with a compact sheet rule", () => {
|
||||
expect(panelSupportsHost("file", "sheet")).toBe(true);
|
||||
expect(panelSupportsHost("agent", "sheet")).toBe(true);
|
||||
expect(panelSupportsHost("provider_subagent", "sheet")).toBe(true);
|
||||
expect(panelSupportsHost("terminal", "sheet")).toBe(false);
|
||||
expect(panelSupportsHost("plugin", "sheet")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets navigation tabs hide their close button without losing their close action", () => {
|
||||
expect(getPanelManifest("files").showCloseButton).toBe(false);
|
||||
expect(getPanelManifest("changes_tree").showCloseButton).toBe(false);
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
export type PaneHost = "main" | "explorer";
|
||||
/** Where a tab renders: a split pane, the Explorer dock, or the compact workspace sheet. */
|
||||
export type PaneHost = "main" | "explorer" | "sheet";
|
||||
|
||||
/** How a target behaves while it is shown in the compact workspace sheet. */
|
||||
export interface CompactSheetPolicy {
|
||||
/** The sheet offers Open as tab, which moves the same tab into the focused pane. */
|
||||
openAsTab: boolean;
|
||||
}
|
||||
|
||||
/** Facts about a target that live outside the layout, resolved lazily by the workspace. */
|
||||
export interface PanelTargetFacts {
|
||||
isWorkspaceRootAgent(agentId: string): boolean;
|
||||
}
|
||||
|
||||
type CompactSheetRule<K extends WorkspaceTabTarget["kind"]> =
|
||||
| CompactSheetPolicy
|
||||
| null
|
||||
| ((
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
facts: PanelTargetFacts,
|
||||
) => CompactSheetPolicy | null);
|
||||
|
||||
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
supportedHosts: readonly PaneHost[];
|
||||
/** The sheet is never listed: a kind renders there exactly when it has a `compactSheet` rule. */
|
||||
supportedHosts: readonly Exclude<PaneHost, "sheet">[];
|
||||
showCloseButton: boolean;
|
||||
singleton: boolean;
|
||||
/**
|
||||
* On compact layouts, revealing this target opens it in the workspace sheet over the current
|
||||
* tab instead of adding a tab. `null` keeps it in the tab strip.
|
||||
*/
|
||||
compactSheet: CompactSheetRule<K>;
|
||||
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
|
||||
}
|
||||
|
||||
@@ -20,6 +46,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "new_tab",
|
||||
},
|
||||
draft: {
|
||||
@@ -27,6 +54,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.draftId,
|
||||
},
|
||||
agent: {
|
||||
@@ -34,6 +62,8 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: (target, facts) =>
|
||||
facts.isWorkspaceRootAgent(target.agentId) ? null : { openAsTab: true },
|
||||
resourceKey: (target) => target.agentId,
|
||||
},
|
||||
provider_subagent: {
|
||||
@@ -41,6 +71,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: { openAsTab: false },
|
||||
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
||||
},
|
||||
terminal: {
|
||||
@@ -48,6 +79,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.terminalId,
|
||||
},
|
||||
browser: {
|
||||
@@ -55,6 +87,7 @@ const manifests = {
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.browserId,
|
||||
},
|
||||
changes_tree: {
|
||||
@@ -62,6 +95,7 @@ const manifests = {
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "changes_tree",
|
||||
},
|
||||
files: {
|
||||
@@ -69,6 +103,7 @@ const manifests = {
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "files",
|
||||
},
|
||||
pull_request: {
|
||||
@@ -76,6 +111,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "pull_request",
|
||||
},
|
||||
file: {
|
||||
@@ -83,6 +119,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: { openAsTab: false },
|
||||
resourceKey: (target) => target.path,
|
||||
},
|
||||
working_diff: {
|
||||
@@ -90,6 +127,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "working_diff",
|
||||
},
|
||||
plugin: {
|
||||
@@ -98,6 +136,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) =>
|
||||
target.context === "agent"
|
||||
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
|
||||
@@ -108,6 +147,7 @@ const manifests = {
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.workspaceId,
|
||||
},
|
||||
commit_diff: {
|
||||
@@ -115,6 +155,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.sha,
|
||||
},
|
||||
} satisfies PanelManifestByKind;
|
||||
@@ -124,7 +165,17 @@ export function getPanelManifest<K extends WorkspaceTabTarget["kind"]>(kind: K):
|
||||
}
|
||||
|
||||
export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHost): boolean {
|
||||
return getPanelManifest(kind).supportedHosts.includes(host);
|
||||
const manifest = getPanelManifest(kind);
|
||||
if (host === "sheet") return manifest.compactSheet !== null;
|
||||
return manifest.supportedHosts.includes(host);
|
||||
}
|
||||
|
||||
export function panelCompactSheetPolicy(
|
||||
target: WorkspaceTabTarget,
|
||||
facts: PanelTargetFacts,
|
||||
): CompactSheetPolicy | null {
|
||||
const rule = getPanelManifest(target.kind).compactSheet as CompactSheetRule<typeof target.kind>;
|
||||
return typeof rule === "function" ? rule(target as never, facts) : rule;
|
||||
}
|
||||
|
||||
export function panelCanLaunchInPane(
|
||||
|
||||
@@ -50,7 +50,7 @@ export function panelTargetSupportsHost(
|
||||
target: WorkspaceTabTarget,
|
||||
host: PaneHost,
|
||||
): boolean {
|
||||
if (target.kind !== "plugin") return panelSupportsHost(target.kind, host);
|
||||
if (target.kind !== "plugin" || host === "sheet") return panelSupportsHost(target.kind, host);
|
||||
const panel = findPluginPanel(serverId, target);
|
||||
// Preserve a persisted unavailable panel where the user left it until its plugin loads again.
|
||||
if (!panel) return true;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesModelSearch,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveSelectedModelLabel,
|
||||
resolveSubmissionReadiness,
|
||||
} from "./provider-selection";
|
||||
@@ -441,3 +442,56 @@ function getAllModelLabels(providers: ReturnType<typeof buildSelectableProviderS
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveEffectiveComposerModeId", () => {
|
||||
const selection = {
|
||||
provider: "pi",
|
||||
modelId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
};
|
||||
|
||||
it("drops a remembered mode when the provider offers no modes", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, false),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("submits the mode the picker shows when the remembered mode is not offered", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId(
|
||||
{
|
||||
...selection,
|
||||
modeId: "plan",
|
||||
modeOptions: [
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "ask", label: "Ask" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("build");
|
||||
});
|
||||
|
||||
it("keeps a selected mode the provider offers", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId(
|
||||
{
|
||||
...selection,
|
||||
modeId: "ask",
|
||||
modeOptions: [
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "ask", label: "Ask" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("ask");
|
||||
});
|
||||
|
||||
it("keeps the selected mode while the provider's modes are still loading", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, true),
|
||||
).toBe("build");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,6 +272,23 @@ export function resolveEffectiveComposerModelId(selection: ProviderSelectionStat
|
||||
);
|
||||
}
|
||||
|
||||
// The mode picker shows the first offered mode when the selected one isn't
|
||||
// offered (e.g. a remembered mode the provider no longer advertises), so the
|
||||
// submitted mode must match that display. A provider with no modes gets none.
|
||||
// While the provider is still loading its modes aren't known yet, so the
|
||||
// selected mode is kept.
|
||||
export function resolveEffectiveComposerModeId(
|
||||
selection: ProviderSelectionState,
|
||||
isModelLoading: boolean,
|
||||
): string {
|
||||
const selectedModeId = selection.modeId.trim();
|
||||
if (isModelLoading) {
|
||||
return selectedModeId;
|
||||
}
|
||||
const modeIds = selection.modeOptions.map((mode) => mode.id);
|
||||
return modeIds.includes(selectedModeId) ? selectedModeId : (modeIds[0] ?? "");
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(
|
||||
selection: ProviderSelectionState,
|
||||
effectiveModelId: string,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { Composer } from "@/composer";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
@@ -891,7 +892,7 @@ function buildWorkspaceDraftSetupFromComposer(input: {
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
modeId: input.composerState.selectedMode || null,
|
||||
modeId: input.composerState.effectiveModeId || null,
|
||||
model: input.composerState.effectiveModelId || null,
|
||||
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: input.composerState.featureValues ?? {},
|
||||
@@ -971,7 +972,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
config: {
|
||||
provider,
|
||||
cwd,
|
||||
modeId: composerState.selectedMode || undefined,
|
||||
modeId: composerState.effectiveModeId || undefined,
|
||||
model: composerState.effectiveModelId || undefined,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
featureValues: composerState.featureValues,
|
||||
@@ -1094,7 +1095,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
return {
|
||||
cwd: workspaceDirectory,
|
||||
provider,
|
||||
modeId: composerState.selectedMode || null,
|
||||
modeId: composerState.effectiveModeId || null,
|
||||
model: composerState.effectiveModelId || null,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: composerState.featureValues,
|
||||
@@ -1636,6 +1637,8 @@ export function NewWorkspaceScreen({
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const toast = useToast();
|
||||
const composerEditing = useNewWorkspaceComposerEditing();
|
||||
const { leaveFullscreen } = composerEditing;
|
||||
const mergeWorkspaces = useCallback(
|
||||
(targetServerId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
|
||||
getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces));
|
||||
@@ -2121,6 +2124,7 @@ export function NewWorkspaceScreen({
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
leaveFullscreen();
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId });
|
||||
},
|
||||
});
|
||||
@@ -2171,6 +2175,7 @@ export function NewWorkspaceScreen({
|
||||
forkDraftSetup,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
leaveFullscreen,
|
||||
selectedServerId,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
@@ -2227,6 +2232,7 @@ export function NewWorkspaceScreen({
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
leaveFullscreen();
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
|
||||
},
|
||||
});
|
||||
@@ -2250,6 +2256,7 @@ export function NewWorkspaceScreen({
|
||||
t,
|
||||
terminalPromptText,
|
||||
toast,
|
||||
leaveFullscreen,
|
||||
updateFormPreferences,
|
||||
withConnectedClient,
|
||||
]);
|
||||
@@ -2369,10 +2376,12 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const importSession = useImportSession({ serverId: selectedServerId });
|
||||
|
||||
const isComposerFullscreen = composerEditing.isFullscreen(isTerminalLaunch);
|
||||
const composer = isTerminalLaunch ? (
|
||||
<Composer
|
||||
key="terminal"
|
||||
inputMode="terminal"
|
||||
editingSession={composerEditing.terminal}
|
||||
readOnly={!terminalTakesPrompt}
|
||||
placeholder={terminalPlaceholder}
|
||||
submitLabel={terminalSubmitLabel}
|
||||
@@ -2399,6 +2408,7 @@ export function NewWorkspaceScreen({
|
||||
) : (
|
||||
<Composer
|
||||
key="chat"
|
||||
editingSession={composerEditing.chat}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
@@ -2429,32 +2439,53 @@ export function NewWorkspaceScreen({
|
||||
);
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<MenuHeader borderless />
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<NewWorkspaceLayout
|
||||
isCompact={isCompact}
|
||||
title={t("newWorkspace.title")}
|
||||
formStack={formStack}
|
||||
onImportSession={importSession.open}
|
||||
>
|
||||
{composer}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</NewWorkspaceLayout>
|
||||
</View>
|
||||
<NewWorkspaceLayout
|
||||
isCompact={isCompact}
|
||||
fullscreen={isComposerFullscreen}
|
||||
title={t("newWorkspace.title")}
|
||||
formStack={formStack}
|
||||
onImportSession={importSession.open}
|
||||
>
|
||||
{composer}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</NewWorkspaceLayout>
|
||||
{importSession.sheet}
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */
|
||||
function useNewWorkspaceComposerEditing() {
|
||||
const terminal = useComposerEditingSession("terminal");
|
||||
const chat = useComposerEditingSession("chat");
|
||||
const isTerminalFullscreen = useComposerFullscreen(terminal);
|
||||
const isChatFullscreen = useComposerFullscreen(chat);
|
||||
// The sessions outlive the screen (its draft keeps them), so a launch that leaves
|
||||
// the screen also leaves fullscreen; the next draft starts inline.
|
||||
const leaveFullscreen = useCallback(() => {
|
||||
terminal.setFullscreen(false);
|
||||
chat.setFullscreen(false);
|
||||
}, [chat, terminal]);
|
||||
return {
|
||||
terminal,
|
||||
chat,
|
||||
leaveFullscreen,
|
||||
isFullscreen: (isTerminalLaunch: boolean) =>
|
||||
isTerminalLaunch ? isTerminalFullscreen : isChatFullscreen,
|
||||
};
|
||||
}
|
||||
|
||||
function NewWorkspaceLayout({
|
||||
isCompact,
|
||||
fullscreen,
|
||||
title,
|
||||
formStack,
|
||||
onImportSession,
|
||||
children,
|
||||
}: {
|
||||
isCompact: boolean;
|
||||
/** The composer covers the screen; the header leaves and the dock fills the content. */
|
||||
fullscreen: boolean;
|
||||
title: string;
|
||||
formStack: ReactNode;
|
||||
onImportSession: () => void;
|
||||
@@ -2472,13 +2503,19 @@ function NewWorkspaceLayout({
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ComposerDock centered={!isCompact}>
|
||||
{setupFields}
|
||||
<>
|
||||
{children}
|
||||
{isCompact ? null : importSessionButton}
|
||||
</>
|
||||
</ComposerDock>
|
||||
<>
|
||||
{fullscreen ? null : <MenuHeader borderless />}
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<ComposerDock centered={!isCompact} fullscreen={fullscreen}>
|
||||
{setupFields}
|
||||
<>
|
||||
{children}
|
||||
{isCompact || fullscreen ? null : importSessionButton}
|
||||
</>
|
||||
</ComposerDock>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ interface FontFamilyRowProps {
|
||||
draft: string;
|
||||
withBorder: boolean;
|
||||
onChangeDraft: (value: string) => void;
|
||||
onCommit: (value: string) => void;
|
||||
onCommit: (value: string) => string;
|
||||
}
|
||||
|
||||
function FontFamilyRow({
|
||||
@@ -255,13 +255,16 @@ function FontFamilyRow({
|
||||
onChangeDraft,
|
||||
onCommit,
|
||||
}: FontFamilyRowProps) {
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const handleCommit = useCallback(() => {
|
||||
onCommit(draft);
|
||||
const committed = onCommit(draft);
|
||||
inputRef.current?.replaceText(committed);
|
||||
}, [draft, onCommit]);
|
||||
|
||||
// Resync from the committed value when it changes elsewhere.
|
||||
useEffect(() => {
|
||||
onChangeDraft(value);
|
||||
inputRef.current?.replaceText(value);
|
||||
// Only resync on external value changes, not on local keystrokes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
@@ -273,6 +276,7 @@ function FontFamilyRow({
|
||||
<Text style={settingsStyles.rowHint}>{hint}</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
initialValue={draft}
|
||||
onChangeText={onChangeDraft}
|
||||
onBlur={handleCommit}
|
||||
@@ -533,12 +537,13 @@ export function AppearanceSection() {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
if (sanitized === null) {
|
||||
setUiFontDraft(settings.uiFontFamily);
|
||||
return;
|
||||
return settings.uiFontFamily;
|
||||
}
|
||||
setUiFontDraft(sanitized);
|
||||
if (sanitized !== settings.uiFontFamily) {
|
||||
void updateSettings({ uiFontFamily: sanitized });
|
||||
}
|
||||
return sanitized;
|
||||
},
|
||||
[settings.uiFontFamily, updateSettings],
|
||||
);
|
||||
@@ -548,12 +553,13 @@ export function AppearanceSection() {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
if (sanitized === null) {
|
||||
setMonoFontDraft(settings.monoFontFamily);
|
||||
return;
|
||||
return settings.monoFontFamily;
|
||||
}
|
||||
setMonoFontDraft(sanitized);
|
||||
if (sanitized !== settings.monoFontFamily) {
|
||||
void updateSettings({ monoFontFamily: sanitized });
|
||||
}
|
||||
return sanitized;
|
||||
},
|
||||
[settings.monoFontFamily, updateSettings],
|
||||
);
|
||||
|
||||
@@ -80,6 +80,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
await vi.importActual<typeof import("react-native")>("react-native");
|
||||
return {
|
||||
BottomSheetBackdrop: () => null,
|
||||
BottomSheetHandle: () => null,
|
||||
BottomSheetModal: ReactModule.forwardRef(() => null),
|
||||
BottomSheetScrollView: ScrollView,
|
||||
BottomSheetTextInput: TextInput,
|
||||
|
||||
@@ -110,3 +110,25 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to
|
||||
}),
|
||||
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
|
||||
});
|
||||
|
||||
test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "main",
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
];
|
||||
const sheetTab: WorkspaceTab = {
|
||||
tabId: "sheet",
|
||||
target: { kind: "agent", agentId: "agent-child" },
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }),
|
||||
).toEqual(["agent-a", "agent-child"]);
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
||||
export function selectVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
/** The workspace sheet's tab, shown over the panes. */
|
||||
sheetTab?: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
}): string[] {
|
||||
@@ -16,20 +18,21 @@ export function selectVisibleAgentIds(input: {
|
||||
? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId)
|
||||
: collectAllPanes(input.layout.root);
|
||||
|
||||
const targets = panes.flatMap((pane) => {
|
||||
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
|
||||
.target;
|
||||
return target ? [target] : [];
|
||||
});
|
||||
if (input.sheetTab) targets.push(input.sheetTab.target);
|
||||
return [
|
||||
...new Set(
|
||||
panes.flatMap((pane) => {
|
||||
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
|
||||
.target;
|
||||
return target?.kind === "agent" ? [target.agentId] : [];
|
||||
}),
|
||||
),
|
||||
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
||||
].sort();
|
||||
}
|
||||
|
||||
export function useVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
sheetTab: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
}): string[] {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import {
|
||||
WorkspaceComposerEditingSessionsProvider,
|
||||
useComposerTabFullscreen,
|
||||
} from "@/composer/editing-session";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||
@@ -73,7 +76,9 @@ import {
|
||||
getFocusedBrowserId,
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
selectExplorerSidebarPaneId,
|
||||
selectPaneFocusSuspended,
|
||||
type WorkspaceLayout,
|
||||
type WorkspaceSheet,
|
||||
type WorkspaceTabPlacement,
|
||||
useWorkspaceLayoutStore,
|
||||
useWorkspaceLayoutStoreHydrated,
|
||||
@@ -160,6 +165,8 @@ import {
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
||||
@@ -201,6 +208,7 @@ import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request";
|
||||
|
||||
const NO_SHEETS: readonly WorkspaceSheet[] = [];
|
||||
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||
@@ -751,22 +759,20 @@ function WorkspaceContentProviders({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePanelContent({
|
||||
function WorkspacePanelProviders({
|
||||
launcher,
|
||||
content,
|
||||
children,
|
||||
tabIds,
|
||||
workspaceKey,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
launcher: NewTabLauncher;
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
tabIds: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
|
||||
<NewTabLauncherProvider value={launcher}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</NewTabLauncherProvider>
|
||||
<NewTabLauncherProvider value={launcher}>{children}</NewTabLauncherProvider>
|
||||
</WorkspaceComposerEditingSessionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -823,6 +829,16 @@ function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): bool
|
||||
return supportsStreamTopOverlay && activeTab?.kind === "agent";
|
||||
}
|
||||
|
||||
/** A fullscreen composer covers the compact header; the column lays out without it. */
|
||||
function resolveCompactHeaderLayout(input: {
|
||||
header: ReactNode;
|
||||
activeTab: WorkspaceTabDescriptor | null;
|
||||
composerFullscreen: boolean;
|
||||
}): { header: ReactNode; floating: boolean } {
|
||||
if (input.composerFullscreen) return { header: null, floating: false };
|
||||
return { header: input.header, floating: floatsHeaderOverContent(input.activeTab) };
|
||||
}
|
||||
|
||||
function shouldShowWorkspaceScreenHeader(input: {
|
||||
isFocusModeEnabled: boolean;
|
||||
isMobile: boolean;
|
||||
@@ -1267,6 +1283,9 @@ function WorkspaceScreenContent({
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null,
|
||||
);
|
||||
const paneFocusSuspended = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? selectPaneFocusSuspended(state, persistenceKey) : false,
|
||||
);
|
||||
const lastMainPaneRef = useLastMainPane({
|
||||
workspaceKey: persistenceKey,
|
||||
layout: workspaceLayout,
|
||||
@@ -1305,6 +1324,13 @@ function WorkspaceScreenContent({
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||
);
|
||||
// Only the top sheet is on screen.
|
||||
const sheetTab = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null,
|
||||
);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
||||
@@ -1377,6 +1403,7 @@ function WorkspaceScreenContent({
|
||||
const visibleAgentIds = useVisibleAgentIds({
|
||||
layout: workspaceLayout,
|
||||
tabs: uiTabs,
|
||||
sheetTab,
|
||||
routeFocused: isRouteFocused,
|
||||
focusedPaneOnly: syncFocusedPaneOnly,
|
||||
});
|
||||
@@ -1678,7 +1705,6 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
const tabId = openPreferredWorkspaceTarget({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
source: "chatFiles",
|
||||
@@ -2062,6 +2088,12 @@ function WorkspaceScreenContent({
|
||||
|
||||
const handleCloseTabById = useCallback(
|
||||
async (tabId: string) => {
|
||||
if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) {
|
||||
if (await confirmDiscardModifiedTab(tabId)) {
|
||||
closeWorkspaceTab(persistenceKey, tabId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tab = allTabDescriptorsById.get(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
@@ -2081,10 +2113,13 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[
|
||||
allTabDescriptorsById,
|
||||
closeWorkspaceTab,
|
||||
confirmDiscardModifiedTab,
|
||||
handleCloseAgentTab,
|
||||
handleClosePassiveTab,
|
||||
handleCloseTerminalTab,
|
||||
persistenceKey,
|
||||
sheets,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2918,17 +2953,19 @@ function WorkspaceScreenContent({
|
||||
tab: WorkspaceTabDescriptor;
|
||||
paneId?: string | null;
|
||||
focusPaneBeforeOpen?: boolean;
|
||||
host?: PaneHost;
|
||||
}) =>
|
||||
buildWorkspacePaneContentModel({
|
||||
tab: input.tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
host:
|
||||
canRenderDesktopPaneSplits &&
|
||||
input.host ??
|
||||
(canRenderDesktopPaneSplits &&
|
||||
input.paneId !== null &&
|
||||
input.paneId === explorerSidebarPaneId
|
||||
? "explorer"
|
||||
: "main",
|
||||
: "main"),
|
||||
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
|
||||
onOpenTab: (target) => {
|
||||
if (!persistenceKey) {
|
||||
@@ -2947,7 +2984,6 @@ function WorkspaceScreenContent({
|
||||
onOpenPreferredTarget: (target, source) => {
|
||||
if (!persistenceKey) return;
|
||||
const tabId = openPreferredWorkspacePreview({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
@@ -3009,7 +3045,6 @@ function WorkspaceScreenContent({
|
||||
canRenderDesktopPaneSplits,
|
||||
openImportSheet,
|
||||
openInSidePane,
|
||||
isMobile,
|
||||
requestFileNavigation,
|
||||
revealWorkspaceChildTab,
|
||||
persistenceKey,
|
||||
@@ -3053,6 +3088,17 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const buildSheetPaneContentModel = useCallback(
|
||||
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) {
|
||||
return buildPaneContentModel({
|
||||
tab,
|
||||
paneId: null,
|
||||
focusPaneBeforeOpen: false,
|
||||
host: "sheet",
|
||||
});
|
||||
},
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
|
||||
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
||||
return;
|
||||
@@ -3068,7 +3114,7 @@ function WorkspaceScreenContent({
|
||||
focusedPaneTabDescriptorMap,
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
paneFocusSuspended: Boolean(unfocusedPaneId),
|
||||
paneFocusSuspended,
|
||||
onFocusPane: handleFocusPane,
|
||||
buildMobilePaneContentModel,
|
||||
});
|
||||
@@ -3495,28 +3541,47 @@ function WorkspaceScreenContent({
|
||||
const desktopContent = desktopSplitContent ?? content;
|
||||
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
|
||||
|
||||
const workspacePanelContent = (
|
||||
<WorkspacePanelContent
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={`${normalizedServerId}:${normalizedWorkspaceId}`}
|
||||
tabIds={uiTabs.map((tab) => tab.tabId)}
|
||||
content={isMobile ? content : desktopContent}
|
||||
/>
|
||||
const workspacePanelTabIds = useMemo(
|
||||
() => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)],
|
||||
[uiTabs, sheets],
|
||||
);
|
||||
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
|
||||
const compactHeaderLayout = resolveCompactHeaderLayout({
|
||||
header: compactWorkspaceHeader,
|
||||
activeTab: activeTabDescriptor,
|
||||
composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId),
|
||||
});
|
||||
|
||||
// The sheet is the column's last child, so it covers the compact header as a modal.
|
||||
const workspaceCenterColumn = isMobile ? (
|
||||
<View style={styles.centerColumn}>
|
||||
<FloatingHeaderLayout
|
||||
header={compactWorkspaceHeader}
|
||||
floating={floatsHeaderOverContent(activeTabDescriptor)}
|
||||
>
|
||||
{(topOverlayInset) => (
|
||||
<StreamTopOverlayProvider value={topOverlayInset}>
|
||||
{workspacePanelContent}
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
</View>
|
||||
<WorkspacePanelProviders
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={workspacePanelKey}
|
||||
tabIds={workspacePanelTabIds}
|
||||
>
|
||||
<View style={styles.centerColumn}>
|
||||
<FloatingHeaderLayout
|
||||
header={compactHeaderLayout.header}
|
||||
floating={compactHeaderLayout.floating}
|
||||
>
|
||||
{(topOverlayInset) => (
|
||||
<StreamTopOverlayProvider value={topOverlayInset}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
<StreamTopOverlayProvider value={0}>
|
||||
<WorkspaceSheetHost
|
||||
workspaceKey={persistenceKey}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
buildContent={buildSheetPaneContentModel}
|
||||
onCloseTab={handleCloseTabById}
|
||||
/>
|
||||
</StreamTopOverlayProvider>
|
||||
</View>
|
||||
</WorkspacePanelProviders>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
|
||||
@@ -3549,7 +3614,15 @@ function WorkspaceScreenContent({
|
||||
</NewTabLauncherProvider>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>{workspacePanelContent}</View>
|
||||
<View style={styles.centerContent}>
|
||||
<WorkspacePanelProviders
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={workspacePanelKey}
|
||||
tabIds={workspacePanelTabIds}
|
||||
>
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
</WorkspacePanelProviders>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user