diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index 418095730a..a4d6a93b5e 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. -Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. +Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline. ## Engine constraints diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index da729381d2..0d63336d59 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -105,6 +105,37 @@ async function seedAgentWithFileLink(input: LinkedFile) { } test.describe("CodeMirror workspace file editing", () => { + test("opens explicit screenshot links and explains missing files", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "screenshot-markdown-link-", + title: "Screenshot file links", + initialPrompt: "Show screenshots", + featureValues: { + mockAssistantResponse: + "[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)", + }, + }); + try { + await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true }); + await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL); + await openAgentRoute(page, session); + await test.step("Open the linked screenshot", async () => { + await openAssistantMarkdownLink(page, "Capture"); + await expectFileTabOpen(page, ".dev/tablet-review/capture.png"); + await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible(); + }); + await test.step("Explain why the missing screenshot cannot open", async () => { + await returnToAssistant(page, session.agentId); + await openAssistantMarkdownLink(page, "Missing capture"); + await expectFileTabOpen(page, ".dev/tablet-review/missing.png"); + await expect(page.getByText(/ENOENT/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); + }); + } finally { + await session.cleanup(); + } + }); + test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "encoded-markdown-link-", diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index 7e1bfac1ae..a893fbcb68 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -2,6 +2,63 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + expectFileTabOpen, + openFileExplorer, + openFileFromExplorer, +} from "../support/helpers/file-explorer"; +import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs"; + +async function openMarkdownPreview(page: Page) { + await openFileExplorer(page); + await openFileFromExplorer(page, "README.md"); + await expectFileTabOpen(page, "README.md"); + await page + .getByTestId("file-panel-bar") + .getByRole("button", { name: "Preview", exact: true }) + .click(); + await ensureWorkspaceAgentPaneVisible(page); +} + +async function followPreviewLink(page: Page, name: string) { + await page + .getByTestId("workspace-file-pane") + .filter({ visible: true }) + .getByRole("link", { name, exact: true }) + .click(); +} + +test("Markdown preview folder links reveal Files and file links still open a file", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "preview-folder-links-" }); + await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true }); + await writeFile( + path.join(workspace.repoPath, "docs/guides/guide.md"), + "# Folder link destination\n", + ); + await writeFile( + path.join(workspace.repoPath, "README.md"), + "[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n", + ); + await workspace.navigateTo(); + + await openMarkdownPreview(page); + await followPreviewLink(page, "Guide"); + await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible(); + + await openMarkdownPreview(page); + await followPreviewLink(page, "Dot"); + await expectFolderContents(page); + await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0); + + await openMarkdownPreview(page); + await followPreviewLink(page, "Bare"); + await expectFolderContents(page); + await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") }); +}); async function openReportedFolderLink(page: Page, name: string) { const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true }); @@ -16,13 +73,21 @@ async function expectFolderContents(page: Page) { ).toBeVisible(); } -for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1", ".github/workflows"]) { +for (const folder of [ + "./docs/guides", + "./docs/guides/", + "./docs/reports.v1", + "./.github/workflows", + "docs/guides", + "docs/guides/", + "docs/指南", +]) { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "folder-link-repro-", title: "Folder link reproduction", initialPrompt: "Show the folder link", - featureValues: { mockAssistantResponse: `[Open folder](./${folder})` }, + featureValues: { mockAssistantResponse: `[Open folder](${folder})` }, }); try { await mkdir(path.join(session.cwd, folder), { recursive: true }); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index e9d43ced27..a34aa259d2 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -2,9 +2,7 @@ import { test, expect } from "../support/fixtures"; import type { Locator } from "@playwright/test"; import { gotoWorkspace, - assertNewChatTileVisible, assertNewTabMenuTriggerVisible, - assertSingleNewTabButton, openNewTabMenuWithShortcut, clickNewChat, clickNewTerminal, @@ -126,6 +124,7 @@ test.describe("Tab creation", () => { page, }) => { await gotoWorkspace(page, workspace.workspaceId); + await assertNewTabMenuTriggerVisible(page); await pressNewTabShortcut(page); const newTabs = page .locator('[data-testid^="workspace-tab-tab_"]') @@ -190,11 +189,18 @@ test.describe("Tab creation", () => { await expect(agent).toBeFocused(); }); - test("clicking new agent tab creates a draft tab", async ({ page }) => { + test("the Agent menu creates a draft and shows its composer within the visual budget", async ({ + page, + }) => { await gotoWorkspace(page, workspace.workspaceId); - await clickNewChat(page); - + const elapsed = await measureTileTransition( + page, + () => clickNewChat(page), + composerLocator(page), + 10_000, + ); + expect(elapsed).toBeLessThan(3_000); await expectComposerVisible(page); const tabsAfter = await getTabTestIds(page); @@ -202,12 +208,17 @@ test.describe("Tab creation", () => { expect(draftCountAfter).toBeGreaterThanOrEqual(1); }); - test("clicking terminal button creates a standalone terminal", async ({ page }) => { + test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => { test.setTimeout(45_000); await gotoWorkspace(page, workspace.workspaceId); - await clickNewTerminal(page); - + const elapsed = await measureTileTransition( + page, + () => clickNewTerminal(page), + terminalSurfaceLocator(page), + 20_000, + ); + expect(elapsed).toBeLessThan(5_000); await expectTerminalSurfaceVisible(page); const tabsAfter = await getTabTestIds(page); @@ -295,13 +306,6 @@ test.describe("Tab creation", () => { await profileSeed.restore(); } }); - - test("tab bar shows action buttons per pane", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - await assertSingleNewTabButton(page); - await assertNewChatTileVisible(page); - await assertNewTabMenuTriggerVisible(page); - }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -339,37 +343,4 @@ test.describe("Tab transitions (no flash)", () => { expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2); await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 }); }); - - test("Terminal transition completes within visual budget", async ({ page }) => { - test.setTimeout(30_000); - await gotoWorkspace(page, workspace.workspaceId); - - const elapsed = await measureTileTransition( - page, - () => clickNewTerminal(page), - terminalSurfaceLocator(page), - 20_000, - ); - - // Terminal surface should appear within a reasonable budget. - // Note: terminal creation involves a server round-trip, so we allow more time - // than a pure in-memory transition, but it should still be well under 5 seconds. - expect(elapsed).toBeLessThan(5_000); - }); - - test("New agent tab click shows composer without flash", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); - - const elapsed = await measureTileTransition( - page, - () => clickNewChat(page), - composerLocator(page), - 10_000, - ); - - // Draft creation is fully in-memory — should be fast - // We use a generous budget here because CI can be slow, but the key assertion - // is that no blank/flash frame appears (tested above). - expect(elapsed).toBeLessThan(3_000); - }); }); diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 84b4cc63a2..4a866a8f44 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; @@ -316,6 +317,47 @@ export default function contribute(plugin) { return directory; } +/** Build command that holds the plugin lifecycle queue until the test writes the release file. */ +const gatedBuildCommand = [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.argv[1], "started");', + "const wait = () => {", + " if (fs.existsSync(process.argv[2])) return;", + " setTimeout(wait, 50);", + "};", + "wait();", +].join("\n"); + +async function createGatedBuildRepository( + root: string, + gate: { started: string; release: string }, +): Promise { + const repository = path.join(root, "gated-repository"); + await mkdir(repository); + await writeFile( + path.join(repository, "paseo-plugin.json"), + JSON.stringify({ + id: "gated-build-plugin", + description: "Holds the plugin lifecycle queue while its build waits", + requirements: pluginRequirements, + build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]], + }), + ); + await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin")); + execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Paseo Tests"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.email", "paseo@example.test"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" }); + return repository; +} + async function installFailedPlugin( page: Page, client: Awaited>, @@ -680,3 +722,155 @@ async function installPluginWithUntrustedDescription( ); await environment.client.installPluginSource({ source: directory }); } + +/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */ +function observePluginReload(page: Page) { + const requests: number[] = []; + const responses: number[] = []; + const isSessionMessage = (payload: unknown, type: string): boolean => { + if (typeof payload !== "string") return false; + try { + const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } }; + const message = envelope.type === "session" ? envelope.message : envelope; + return message?.type === type; + } catch { + return false; + } + }; + page.on("websocket", (socket) => { + // The reload request travels client -> daemon (outbound); the answer comes back inbound. + socket.on("framesent", ({ payload }) => { + if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now()); + }); + socket.on("framereceived", ({ payload }) => { + if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now()); + }); + }); + return { + waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1), + waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1), + requestedAt: () => requests[0]!, + requestCount: () => requests.length, + }; +} + +/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */ +async function holdPluginLifecycleQueue( + client: PluginEnvironment["client"], + root: string, +): Promise<{ release: () => Promise; settle: () => Promise }> { + const gate = { + started: path.join(root, "build-started"), + release: path.join(root, "build-release"), + }; + const repository = await createGatedBuildRepository(root, gate); + const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` }); + // The marker wait below can outlive an early failure: a refused preparation, a dropped + // connection, or a build command that exits before writing its marker. Handle that rejection now + // so it never surfaces as an unhandled one, and drain the same promise in finish(). + const installDrained = install.catch(() => undefined); + // Every path releases the gate and drains the install: a build still polling for its gate holds + // the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall. + const finish = async () => { + await writeFile(gate.release, "go").catch(() => undefined); + await installDrained; + }; + try { + // Whichever comes first decides the scenario: the build reports that it started, or the setup + // fails with the daemon's own error instead of a bare marker timeout. + await Promise.race([ + expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true), + install, + ]); + } catch (error) { + // Setup that never reached the build must not hand the caller a queue it cannot settle. + await finish(); + throw error; + } + return { + release: () => writeFile(gate.release, "go"), + settle: finish, + }; +} + +function pluginRowActions(page: Page, pluginId: string) { + return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }); +} + +async function expectPluginRowPending(page: Page, pluginId: string): Promise { + await expect(pluginRowActions(page, pluginId)).toBeDisabled(); +} + +async function expectPluginRowSettled(page: Page, pluginId: string): Promise { + await expect(pluginRowActions(page, pluginId)).toBeEnabled(); + await expect(page.getByLabel(`${pluginId} running`)).toBeVisible(); +} + +async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise { + await page.screenshot({ + path: testInfo.outputPath(name), + fullPage: true, + animations: "disabled", + }); +} + +/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */ +async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise { + const remaining = 66_000 - (Date.now() - requestedAt); + if (remaining > 0) await page.waitForTimeout(remaining); +} + +test("keeps Reload pending past a minute while the plugin queue is busy", async ({ + page, + pluginEnvironment, +}, testInfo) => { + test.setTimeout(180_000); + const { client, directory } = pluginEnvironment; + const pluginDirectory = await createDirectoryPlugin( + directory, + "e2e-plugin", + "Exercises the complete local plugin lifecycle", + "Plugin v1", + ); + + // Observe before the app loads: its daemon socket comes up with the page. + const reload = observePluginReload(page); + await gotoAppShell(page); + await openPluginSettings(page); + // Plugins on is a precondition here, not the subject: set it through the daemon instead of the + // switch, whose transient "Plugins enabled" confirmation this scenario must not depend on. + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: pluginDirectory }); + await expectPluginRowSettled(page, "e2e-plugin"); + + const queue = await holdPluginLifecycleQueue(client, directory); + try { + await selectPluginAction(page, "e2e-plugin", "Reload"); + await reload.waitForRequest(); + // Pending: the row's actions stay disabled, so a second reload cannot be submitted. + await expectPluginRowPending(page, "e2e-plugin"); + await capturePluginSurface(page, testInfo, "plugin-reload-pending.png"); + + await waitPastFormerReloadDeadline(page, reload.requestedAt()); + + // Past the former deadline the reload is still pending, the row shows its previous state, and + // no timeout error reached the surface. + await expectPluginRowPending(page, "e2e-plugin"); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0); + await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png"); + + await queue.release(); + // The daemon's answer only arrives once the plugin really started again; a request timeout + // would have shown up as feedback during the wait above. + await reload.waitForResponse(); + expect(reload.requestCount()).toBe(1); + await expect(page.getByTestId("plugin-management-feedback")).toContainText( + "Reloaded e2e-plugin", + ); + await expectPluginRowSettled(page, "e2e-plugin"); + await capturePluginSurface(page, testInfo, "plugin-reload-completed.png"); + } finally { + await queue.settle(); + } +}); diff --git a/packages/app/e2e/browser/projects-settings.spec.ts b/packages/app/e2e/browser/projects-settings.spec.ts index 3845e017a6..0612d5ce07 100644 --- a/packages/app/e2e/browser/projects-settings.spec.ts +++ b/packages/app/e2e/browser/projects-settings.spec.ts @@ -223,7 +223,10 @@ test.describe("Projects settings", () => { } }); - test("user edits worktree setup from the projects page", async ({ page, editableProject }) => { + test("edits worktree setup, navigates within the host, and removes a script", async ({ + page, + editableProject, + }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); await expectNoUncommittedSetupWarning(page); @@ -236,15 +239,22 @@ test.describe("Projects settings", () => { await returnToProjectsList(page); await openProjectSettings(page, editableProject.name); await expectNoUncommittedSetupWarning(page); - }); - test("project navigation stays inside the selected host", async ({ page, editableProject }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - await expectProjectHostContextHidden(page); - await returnToProjectsList(page); - await openProjectSettings(page, editableProject.name); - await expectProjectSettingsHistoryRoundTrip(page, editableProject.name); + await test.step("navigation stays inside the selected host", async () => { + await expectProjectHostContextHidden(page); + await returnToProjectsList(page); + await openProjectSettings(page, editableProject.name); + await expectProjectSettingsHistoryRoundTrip(page, editableProject.name); + }); + + await test.step("removing a script leaves the committed setup unchanged", async () => { + await expectScriptRowCount(page, 1); + await removeProjectScript(page, "dev"); + await expectScriptRowCount(page, 0); + await expectEmptyScriptList(page); + await clickSaveProjectSettings(page); + await expectNoUncommittedSetupWarning(page); + }); }); test("user edits worktree setup on a non-GitHub remote project", async ({ @@ -259,18 +269,7 @@ test.describe("Projects settings", () => { await expectProjectConfigSaved(gitlabRemoteProject); }); - test("user renames a project from the edit sheet", async ({ page, editableProject }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - await openProjectEditSheet(page); - await fillProjectName(page, "Renamed project"); - await saveProjectEdits(page); - - await expectProjectEditSaved(page); - await expectProjectTitle(page, "Renamed project"); - }); - - test("reopening the edit sheet seeds from the saved project", async ({ + test("renames a project, reopens the saved edit sheet, and adds an icon", async ({ page, editableProject, }) => { @@ -279,26 +278,26 @@ test.describe("Projects settings", () => { await openProjectEditSheet(page); await fillProjectName(page, "Renamed project"); await saveProjectEdits(page); + await expectProjectEditSaved(page); + await expectProjectTitle(page, "Renamed project"); - await openProjectEditSheet(page); - - await expectProjectEditName(page, "Renamed project"); - await expectProjectEditsSaveDisabled(page); - }); - - test("user picks a custom project icon from a file", async ({ page, editableProject }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - await openProjectEditSheet(page); - await chooseProjectIconImage(page, { - name: "logo.png", - mimeType: "image/png", - buffer: PNG_1X1, + await test.step("reopening seeds the saved name with no unsaved changes", async () => { + await openProjectEditSheet(page); + await expectProjectEditName(page, "Renamed project"); + await expectProjectEditsSaveDisabled(page); }); - await saveProjectEdits(page); - await expectProjectEditSaved(page); + await test.step("an icon can be saved without another name change", async () => { + await chooseProjectIconImage(page, { + name: "logo.png", + mimeType: "image/png", + buffer: PNG_1X1, + }); + await saveProjectEdits(page); + await expectProjectEditSaved(page); + await expectProjectTitle(page, "Renamed project"); + }); }); test("user sets a project name and icon in one save", async ({ page, editableProject }) => { @@ -459,31 +458,4 @@ test.describe("Projects settings — error UX", () => { await expectNoEditableTarget(page); }); - - test("project detail does not render a second host selector", async ({ - page, - editableProject, - }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - - await expectProjectHostContextHidden(page); - }); - - test("script removal via kebab menu removes the row from the form", async ({ - page, - editableProject, - }) => { - await openProjects(page); - await openProjectSettings(page, editableProject.name); - - await expectScriptRowCount(page, 1); - - await removeProjectScript(page, "dev"); - - await expectScriptRowCount(page, 0); - await expectEmptyScriptList(page); - await clickSaveProjectSettings(page); - await expectNoUncommittedSetupWarning(page); - }); }); diff --git a/packages/app/e2e/browser/provider-settings-refresh.spec.ts b/packages/app/e2e/browser/provider-settings-refresh.spec.ts index efc4fc7f88..961b518435 100644 --- a/packages/app/e2e/browser/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/browser/provider-settings-refresh.spec.ts @@ -116,28 +116,12 @@ async function exerciseProviderSettingsStack(page: Page) { await page.getByRole("button", { name: "Refresh", exact: true }).click(); await expectProviderSettingsVisible(page); + await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({ + timeout: 30_000, + }); } test.describe("provider settings overlay stack", () => { - test("provider model update age says just now", async ({ page }) => { - const session = await seedMockAgentWorkspace({ - repoPrefix: "provider-relative-time-", - title: "Provider relative time", - }); - try { - await page.clock.install({ time: Date.now() }); - await page.setViewportSize(MOBILE_VIEWPORT); - await openAgentRoute(page, session); - await expectComposerVisible(page); - await openProviderSettingsFromModelSelector(page); - await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({ - timeout: 30_000, - }); - } finally { - await session.cleanup(); - } - }); - test("provider settings covers the desktop model selector without closing it", async ({ page, }) => { diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 8071b3b4f3..85de01a376 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => { await expectMobileAgentSidebarHidden(page); }); + test("places the close button beside the first nav row instead of over it", async ({ page }) => { + await gotoAppShell(page); + await openMobileAgentSidebar(page); + await expectMobileAgentSidebarVisible(page); + + const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox(); + const closeButton = await page.getByTestId("sidebar-close").boundingBox(); + if (!firstRow || !closeButton) throw new Error("first nav row and close button must render"); + expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width); + }); + test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({ page, }) => { diff --git a/packages/app/e2e/browser/workspace-cwd.spec.ts b/packages/app/e2e/browser/workspace-cwd.spec.ts deleted file mode 100644 index e702cc0f7d..0000000000 --- a/packages/app/e2e/browser/workspace-cwd.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect, test } from "../support/fixtures"; -import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher"; -import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; -import { - expectTerminalSurfaceVisible, - focusTerminalSurface, - typeInTerminal, - setupDeterministicPrompt, - waitForTerminalContent, -} from "../support/helpers/terminal-perf"; - -interface CreatedAgentCwdAssertion { - workspaceId: string; - cwd: string | null; -} - -async function fetchSingleAgentForWorkspace( - workspace: SeededWorkspace, -): Promise { - const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries - .map((entry) => entry.agent) - .filter((agent) => agent.workspaceId === workspace.workspaceId); - if (agents.length !== 1) { - return null; - } - const [agent] = agents; - return { - workspaceId: workspace.workspaceId, - cwd: agent.cwd, - }; -} - -test.describe("Workspace cwd correctness", () => { - test("main checkout workspace opens terminals in the project root", async ({ - page, - withWorkspace, - }) => { - test.setTimeout(60_000); - - const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" }); - await workspace.navigateTo(); - await clickNewTerminal(page); - - await expectTerminalSurfaceVisible(page); - await focusTerminalSurface(page); - await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); - await typeInTerminal(page, "pwd\n"); - await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); - }); - - test("draft tab creates an agent in the workspace cwd", async ({ page }) => { - test.setTimeout(60_000); - - const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" }); - try { - await gotoWorkspace(page, workspace.workspaceId); - - await clickNewChat(page); - const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); - const message = `cwd draft create ${Date.now()}`; - await expect(composer).toBeEditable({ timeout: 15_000 }); - await composer.fill(message); - await composer.press("Enter"); - await expect(page.getByText(message, { exact: true }).first()).toBeVisible({ - timeout: 30_000, - }); - - await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({ - timeout: 30_000, - }); - - await expect - .poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 }) - .toEqual({ - workspaceId: workspace.workspaceId, - cwd: workspace.repoPath, - }); - } finally { - await workspace.cleanup(); - } - }); - - test("worktree workspace opens terminals in the worktree directory", async ({ - page, - withWorkspace, - }) => { - test.setTimeout(90_000); - - const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" }); - await workspace.navigateTo(); - await clickNewTerminal(page); - - await expectTerminalSurfaceVisible(page); - await focusTerminalSurface(page); - await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`); - await typeInTerminal(page, "pwd\n"); - await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000); - }); -}); diff --git a/packages/app/e2e/browser/workspace-lifecycle.spec.ts b/packages/app/e2e/browser/workspace-lifecycle.spec.ts index 7c7a20658f..83659cd001 100644 --- a/packages/app/e2e/browser/workspace-lifecycle.spec.ts +++ b/packages/app/e2e/browser/workspace-lifecycle.spec.ts @@ -3,6 +3,7 @@ import { createAgentChatFromLauncher, createStandaloneTerminalFromLauncher, expectTerminalCwd, + submitAgentChatAndExpectCwd, } from "../support/helpers/workspace-lifecycle"; test.describe("Workspace lifecycle", () => { @@ -19,8 +20,9 @@ test.describe("Workspace lifecycle", () => { const workspace = await withWorkspace({ prefix: "lifecycle-main-" }); await workspace.navigateTo(); - await test.step("creates an agent chat", async () => { + await test.step("submitting a draft creates an agent in the project root", async () => { await createAgentChatFromLauncher(page); + await submitAgentChatAndExpectCwd(page, workspace); }); await test.step("creates a terminal in the project root", async () => { diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index cf4a6b14a0..e967dc4d6d 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -80,13 +80,6 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis // ─── Tab bar assertions ─────────────────────────────────────────────────── -/** Assert the inline plus button is visible in the tab bar. */ -export async function assertNewChatTileVisible(page: Page): Promise { - await expect( - page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(), - ).toBeVisible(); -} - /** Assert the New tab button is visible in the tab bar. */ export async function assertNewTabMenuTriggerVisible(page: Page): Promise { await expect( @@ -132,13 +125,6 @@ export async function waitForTabWithTitle( ).toBeVisible({ timeout }); } -/** Assert the inline plus button is visible in the tab bar. */ -export async function assertSingleNewTabButton(page: Page): Promise { - const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true }); - const count = await buttons.count(); - expect(count).toBeGreaterThanOrEqual(1); -} - // ─── No-flash measurement ────────────────────────────────────────────────── /** diff --git a/packages/app/e2e/support/helpers/workspace-lifecycle.ts b/packages/app/e2e/support/helpers/workspace-lifecycle.ts index 383872850a..dde9490dea 100644 --- a/packages/app/e2e/support/helpers/workspace-lifecycle.ts +++ b/packages/app/e2e/support/helpers/workspace-lifecycle.ts @@ -1,5 +1,6 @@ import { expect, type Page } from "@playwright/test"; import { clickNewChat, clickNewTerminal } from "./launcher"; +import { connectSeedClient } from "./seed-client"; import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf"; function terminalSurface(page: Page) { @@ -30,3 +31,35 @@ export async function createAgentChatFromLauncher(page: Page): Promise { await expect(composerInput(page)).toBeEditable({ timeout: 15_000 }); await expect(page.getByTestId("agent-loading")).toHaveCount(0); } + +export async function submitAgentChatAndExpectCwd( + page: Page, + workspace: { workspaceId: string; repoPath: string }, +): Promise { + const client = await connectSeedClient(); + try { + const message = "cwd draft create"; + await composerInput(page).fill(message); + await composerInput(page).press("Enter"); + await expect(page.getByText(message, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({ + timeout: 30_000, + }); + await expect + .poll( + async () => { + const agents = await client.fetchAgents({ scope: "active" }); + return agents.entries + .map((entry) => entry.agent) + .filter((agent) => agent.workspaceId === workspace.workspaceId) + .map((agent) => ({ workspaceId: agent.workspaceId, cwd: agent.cwd })); + }, + { timeout: 30_000 }, + ) + .toEqual([{ workspaceId: workspace.workspaceId, cwd: workspace.repoPath }]); + } finally { + await client.close(); + } +} diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 1904c11907..4fb41a72ca 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0 const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i; export interface AssistantHrefParseOptions { workspaceRoot?: string; + /** Authored links identify a file; only inferred text needs filename heuristics. */ + explicitLink?: boolean; /** * True only when `value` is a markdown href — the renderer percent-encodes * non-ASCII and space characters, so the path must be decoded. Raw path @@ -234,7 +236,7 @@ export function classifyAssistantFileLink( return null; } - if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) { + if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) { return { kind: "ambiguousFileCandidate", target, @@ -278,6 +280,7 @@ export function parseAssistantFileLink( const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { workspaceRoot: options.workspaceRoot, decodeHref: options.decodeHref, + explicitLink: options.explicitLink, }); if (relativeTarget) { return relativeTarget; @@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink( function parseLocalPathParts( value: string, - options: Pick = {}, + options: Pick = {}, ): { path: string; lines: Pick; @@ -437,7 +440,7 @@ function parseLocalPathParts( if (options.decodeHref) { pathPart = safeDecodeURIComponent(pathPart); } - if (!isPlausibleAssistantLocalPath(pathPart)) { + if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) { return null; } return { @@ -458,7 +461,7 @@ function parseLocalPathParts( decodedBeforeHash = safeDecodeURIComponent(beforeHash); } const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash; - if (!isPlausibleAssistantLocalPath(plausiblePath)) { + if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) { return null; } diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 384c3b6857..96884c21c7 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -18,6 +18,63 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])( + "resolves an explicit extensionless folder href: %s", + (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({ + kind: "resolved", + value: { + kind: "file", + target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` }, + }, + }); + }, +); + +it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => { + expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); +}); + +it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])( + "keeps auto-recognized extensionless text ignored: %j", + (origin) => { + expect( + classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT), + ).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); + }, +); + +it.each(["docs/guides?view=all"])( + "does not turn URL-like hrefs into workspace paths: %s", + (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); + }, +); + +it.each([ + ".dev/tablet-review/tablet-agent-draft.png", + "images/capture.jpg", + "downloads/archive.custom", + "assets/LICENSE", + "example.com/guides", + "capture.png", + "missing.png", +])("opens explicit file link %s without filename guessing", (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({ + kind: "resolved", + value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } }, + }); +}); + it("opens the existing non-ASCII file named by a rendered markdown link", async () => { const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-")); try { diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 774d39278b..be012077a3 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -117,6 +117,7 @@ export function classifyForResolution( const classification = classifyAssistantFileLink(token, { workspaceRoot: context.workspaceRoot, decodeHref: tokenInfo.fromHref, + explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source), }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } }; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 943c1edd27..58d0dee053 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -21,8 +21,14 @@ import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { + IconButtonChromeProvider, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; import { SIDEBAR_RESIZE_ACTIVATION_OFFSET, @@ -62,6 +68,7 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; +import type { ShortcutKey } from "@/utils/format-shortcut"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; import { @@ -74,6 +81,8 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; +const ThemedX = withUnistyles(X); + type SidebarTheme = ReturnType["theme"]; const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; @@ -545,6 +554,11 @@ function MobileSidebar({ closeSidebar(); }, [closeSidebar]); + const closeButton = useMemo( + () => , + [labels.closeSidebar, closeSidebar], + ); + const mobileSidebarInsetStyle = useMemo( () => ({ paddingTop: insetsTop, @@ -562,30 +576,11 @@ function MobileSidebar({ > - - - - {({ hovered, pressed }) => ( - - )} - - + {isInitialLoad && !hasActiveHostFilter ? ( @@ -626,6 +621,34 @@ function MobileSidebar({ ); } +const NO_SHORTCUT: ShortcutKey[] = []; + +/** The compact header's icon button, so the sidebar's close reads like the header it covers. */ +function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + + + + + ); +} + function DesktopSidebar({ theme, workspaceGroups, @@ -856,24 +879,8 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - mobileCloseButtonRow: { - position: "absolute", - top: theme.spacing[3], - left: 0, - right: 0, - zIndex: 2, - alignItems: "flex-end", - }, mobileCloseButton: { - // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. - // This optical inset puts their painted right edges on the same sidebar rail. - marginRight: theme.spacing[2] + 1.5, - width: 32, - height: 32, - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surfaceSidebar, + marginRight: theme.spacing[2], }, desktopSidebarBorder: { borderRightWidth: 1, diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 218cdc8884..8791cf40de 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,5 +1,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; +import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; +import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; +import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import { QueryClient } from "@tanstack/react-query"; import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding"; import { describe, expect, it } from "vitest"; @@ -31,6 +34,56 @@ describe("shared Markdown source adaptation", () => { "> - ![Image]()\n\n[picture]: ../a.png", ); }); + it("gives an image alone on its line its own paragraph", () => { + expect( + prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(), + ).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it."); + expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe( + "![A](https://x/a.png)\n\n![B](https://x/b.png)", + ); + expect( + prepareMarkdownSource( + "Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three", + ).render(), + ).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three"); + }); + it("keeps images that share a line with text inline", () => { + const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line"; + expect(prepareMarkdownSource(source).render()).toBe(source); + }); + it("splits own-line images inside quotes and list items without ending the container", () => { + expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe( + "> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More", + ); + expect( + prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(), + ).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next"); + expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe( + "- ![Shot](https://x/a.png)\n\n Caption line", + ); + }); + it("renders own-line images as blocks through the library parser", async () => { + const source = + "Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)"; + const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { + hardSoftBreaks: true, + latexMath: false, + }); + markInlineImages(tree); + const images: ASTNode[] = []; + const collect = (node: ASTNode) => { + if (node.type === "Image") images.push(node); + node.children?.forEach(collect); + }; + collect(tree); + expect( + images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]), + ).toEqual([ + ["https://x/a.png", false], + ["https://x/i.png", true], + ["https://x/q.png", false], + ]); + }); it("escapes spoiler delimiters only in prose", () => { expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe( "Show \\|\\|words\\|\\| and `||code||`.", diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index fc6f7463a5..9fda25b728 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -5,7 +5,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import type { AssistantFileLinkSource } from "@/assistant-file-links"; import { decodeString } from "micromark-util-decode-string"; import { fromMarkdown } from "mdast-util-from-markdown"; -import type { Link, Nodes, Root } from "mdast"; +import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast"; // Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex. // The Unicode flag makes Metro expand it to escapes rejected by native engines. @@ -130,10 +130,50 @@ export function prepareMarkdownSource( end, }); } + // Line boundaries already turned into paragraph breaks, by offset of their newline. + const splitBoundaries = new Set(); + function isImageLike(node: PhrasingContent): boolean { + if (node.type === "image" || node.type === "imageReference") return true; + return ( + (node.type === "link" || node.type === "linkReference") && + node.children.length === 1 && + isImageLike(node.children[0]) + ); + } + // An image alone on its line becomes its own paragraph, so every platform renders + // it as a block. The library only treats sole-child paragraph images as blocks; + // with hardSoftBreaks every newline is a break node, which made these inline. + function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) { + for (const child of paragraph.children) { + const childStart = child.position?.start.offset; + const childEnd = child.position?.end.offset; + if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue; + const lineStart = source.lastIndexOf("\n", childStart - 1) + 1; + const newline = source.indexOf("\n", childEnd); + const lineEnd = newline === -1 ? source.length : newline; + const prefix = source.slice(lineStart, childStart); + const suffix = source.slice(childEnd, lineEnd); + const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix); + const aloneAfter = /^\s*\\?$/.test(suffix); + if (!aloneBefore || !aloneAfter) continue; + // Continuation lines inside quotes and lists keep their quote markers on the blank line. + const blank = prefix.replace(/[^>]/g, " ").trimEnd(); + const split = (boundary: number) => { + if (splitBoundaries.has(boundary)) return; + splitBoundaries.add(boundary); + // A trailing backslash was a hard break; at a paragraph end it would render literally. + const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary; + edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` }); + }; + if (lineStart > start) split(lineStart - 1); + if (lineEnd < end) split(lineEnd); + } + } function visit(node: Nodes) { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start !== undefined && end !== undefined) { + if (node.type === "paragraph") splitStandaloneImages(node, start, end); if (node.type === "link") { const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); if (title !== null) diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx index 425280207e..a1481dcff9 100644 --- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -1,8 +1,9 @@ import { router, usePathname } from "expo-router"; import { CalendarClock, History, Plus, Search } from "lucide-react-native"; -import { memo, useCallback, useMemo, useRef, type ComponentType } from "react"; +import { memo, useCallback, useMemo, useRef, type ComponentType, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { PluginSidebarItem } from "@/plugins/sidebar-items"; @@ -30,37 +31,49 @@ interface SidebarNavRowProps { interface SidebarNavRowsProps extends SidebarNavRowProps { /** Style for the group wrapper, which the sidebar owns. */ style?: StyleProp; + /** Sits to the right of the first row, which shrinks to make room. */ + firstRowTrailing?: ReactNode; } /** * Top-level sidebar navigation, ordered and filtered by the user's * `sidebarNavItems` preference. Renders nothing — not even the bordered group - * wrapper — when every item is hidden. + * wrapper — when every item is hidden and there is no `firstRowTrailing`. */ -export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) { +export function SidebarNavRows({ style, onBeforeNavigate, firstRowTrailing }: SidebarNavRowsProps) { const { items } = useSidebarNavItems("header"); const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]); const groupRef = useRef(null); - if (visibleItems.length === 0) return null; + if (visibleItems.length === 0 && !firstRowTrailing) return null; + + const [firstRow, ...restRows] = visibleItems.map((item) => { + if (item.kind === "plugin") { + return ( + + ); + } + const Row = BUILTIN_ROWS[item.id]; + return ; + }); return ( - {visibleItems.map((item) => { - if (item.kind === "plugin") { - return ( - - ); - } - const Row = BUILTIN_ROWS[item.id]; - return ; - })} + {firstRowTrailing ? ( + + {firstRow} + {firstRowTrailing} + + ) : ( + firstRow + )} + {restRows} ); } @@ -179,3 +192,14 @@ const BUILTIN_ROWS: Record ({ }, // Lifts the composer off the chat it sits under on phones. inputWrapperCompact: { - boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)", + ...theme.shadow.sm, }, // Dotted says "this surface is the same box, but there is nothing to type // into it" without swapping the border colour, which reads as an error state. diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2f1fd7397f..4adf945b61 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1334,8 +1334,9 @@ export const ar: TranslationResources = { }, fields: { project: "المشروع", - base: "الأساس", + branch: "الفرع", baseNotApplicable: "غير قابل للتطبيق", + host: "المضيف", }, titlePlaceholder: "العنوان (اختياري)", errors: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 14cde732e7..9bb0460036 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1342,8 +1342,9 @@ export const en = { }, fields: { project: "Project", - base: "Base", + branch: "Branch", baseNotApplicable: "Not applicable", + host: "Host", }, titlePlaceholder: "Title (optional)", errors: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 18eb17f291..9ed1e85572 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1370,8 +1370,9 @@ export const es: TranslationResources = { }, fields: { project: "Proyecto", - base: "Base", + branch: "Rama", baseNotApplicable: "No aplicable", + host: "Host", }, titlePlaceholder: "Título (opcional)", errors: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 2bae7b8647..6563b1100e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1363,8 +1363,9 @@ export const fr: TranslationResources = { }, fields: { project: "Projet", - base: "Base", + branch: "Branche", baseNotApplicable: "Non applicable", + host: "Hôte", }, titlePlaceholder: "Titre (facultatif)", errors: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 58e2706b95..eb22dd663a 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1348,8 +1348,9 @@ export const ja: TranslationResources = { }, fields: { project: "プロジェクト", - base: "ベース", + branch: "ブランチ", baseNotApplicable: "該当なし", + host: "ホスト", }, titlePlaceholder: "タイトル(任意)", errors: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index fa962a5562..afdb8974e9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1341,8 +1341,9 @@ export const ko: TranslationResources = { }, fields: { project: "프로젝트", - base: "기준", + branch: "브랜치", baseNotApplicable: "해당 없음", + host: "호스트", }, titlePlaceholder: "제목(선택 사항)", errors: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c5c6607ca6..f89fb97bf0 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1360,8 +1360,9 @@ export const ptBR: TranslationResources = { }, fields: { project: "Projeto", - base: "Base", + branch: "Branch", baseNotApplicable: "Não aplicável", + host: "Host", }, titlePlaceholder: "Título (opcional)", errors: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4df3f721bb..a1a70822b4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1352,8 +1352,9 @@ export const ru: TranslationResources = { }, fields: { project: "Проект", - base: "Базовая ветка", + branch: "Ветка", baseNotApplicable: "Неприменимо", + host: "Хост", }, titlePlaceholder: "Заголовок (необязательно)", errors: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index bad9cc4690..ca221d7d3a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = { }, fields: { project: "项目", - base: "基线", + branch: "分支", baseNotApplicable: "不适用", + host: "主机", }, titlePlaceholder: "标题(可选)", errors: { diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 2dea552239..98e0ea29b3 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1207,6 +1207,60 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it.each(["projects", "workspaces"] as const)( + "requests projects and workspaces together and commits once when %s responds first", + async (firstResponse) => { + const serverId = `parallel-directory-${firstResponse}`; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, projectList: true }, + }); + const releaseProjects = client.holdProjectFetch(); + const releaseWorkspaces = client.holdWorkspaceFetch(); + const project = { + projectId: "parallel-project", + projectDisplayName: "Project baseline", + projectRootPath: "/repo/parallel", + projectKind: "git" as const, + }; + const projects: ProjectListResult = { requestId: "projects", projects: [project] }; + const workspaces: WorkspaceFetchResult = { + requestId: "workspaces", + entries: [createWorkspaceEntry("parallel-workspace")], + emptyProjects: [{ ...project, projectDisplayName: "Newer workspace overlay" }], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }; + const refresh = directory.refreshWorkspaces(); + try { + await expect.poll(() => client.listProjectsCalls).toBe(1); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); + if (firstResponse === "projects") releaseProjects(projects); + else releaseWorkspaces(workspaces); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(useSessionStore.getState().sessions[serverId].workspaces.size).toBe(0); + expect(useSessionStore.getState().sessions[serverId].projects.size).toBe(0); + releaseProjects(projects); + releaseWorkspaces(workspaces); + await refresh; + const session = useSessionStore.getState().sessions[serverId]; + expect(session.workspaces.has("parallel-workspace")).toBe(true); + expect(session.projects.get(project.projectId)?.projectDisplayName).toBe( + "Newer workspace overlay", + ); + } finally { + releaseProjects(projects); + releaseWorkspaces(workspaces); + await refresh; + directory.dispose(); + } + }, + ); + it("keeps the accepted baseline when an old connection finishes its workspace request", async () => { const serverId = "superseded-workspace-baseline"; const { client, directory } = createDirectory(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 28b217fea6..b59440d782 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -563,16 +563,20 @@ export class DirectorySync { const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; const supportsProjectList = serverInfo?.features?.projectList === true; const supportsDirectorySync = serverInfo?.features?.directorySync === true; - if (supportsProjectList) { - await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync); - } - await this.fetchWorkspaceSnapshot( - client, - source, - transaction, - input?.subscribe === true, - supportsDirectorySync, - ); + const projectsReady = supportsProjectList + ? this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync) + : Promise.resolve(); + await Promise.all([ + projectsReady, + this.fetchWorkspaceSnapshot( + client, + source, + transaction, + input?.subscribe === true, + supportsDirectorySync, + projectsReady, + ), + ]); if (!supportsProjectList) { this.buildLegacyProjectSnapshot(transaction.snapshot); (transaction.snapshot.syncModes ??= {}).projects = @@ -592,6 +596,7 @@ export class DirectorySync { transaction: DirectoryTransaction, initialSubscribe: boolean, supportsDirectorySync: boolean, + projectsReady: Promise, ): Promise { let cursor: string | null = null; let subscribe = initialSubscribe; @@ -618,6 +623,9 @@ export class DirectorySync { }); payload = await subscription.ready; } else payload = await client.fetchWorkspaces(query); + // Fetch both directories together, but apply the project baseline before the + // workspace page's empty-project overlays, regardless of response order. + await projectsReady; this.assertWorkspaceTransactionCurrent(client, source, transaction); applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 6d8fd3de5a..3cb01ae9df 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -6,15 +6,13 @@ import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/ import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import type { ReactElement, ReactNode, RefObject } from "react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { Pressable, Text, View } from "react-native"; -import type { PressableStateCallbackType } from "react-native"; -import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { router } from "expo-router"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; 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"; @@ -23,14 +21,6 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { HostStatusDot } from "@/components/host-status-dot"; -import { HostPicker } from "@/components/hosts/host-picker"; -import { ProjectIconView } from "@/components/project-icon-view"; -import { Combobox, ComboboxItem } from "@/components/ui/combobox"; -import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox"; -import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { Shortcut } from "@/components/ui/shortcut"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { MenuHeader } from "@/components/headers/menu-header"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -42,7 +32,6 @@ import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { resolveTerminalProfiles } from "@getpaseo/protocol/terminal-profiles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { LaunchControl } from "@/new-workspace-launch/launch-control"; import { resolveLaunchTarget, type LaunchTarget } from "@/new-workspace-launch/target"; import { useTerminalComposerState } from "@/new-workspace-launch/composer-state"; import { runCreateTerminalWorkspace } from "./new-workspace-terminal"; @@ -71,12 +60,10 @@ import { import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { toErrorMessage } from "@/utils/error-messages"; -import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { buildSettingsAddHostRoute } from "@/utils/host-routes"; import { - getHostProjectSourceDirectory, getHostProjectId, getWorktreeSupportForHostProject, hostProjectFromRoute, @@ -86,7 +73,6 @@ import { type HostProjectListItem, } from "@/projects/host-projects"; import { useProjectIcons } from "@/projects/icons"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; @@ -122,6 +108,7 @@ import { } from "./new-workspace-initial-context"; import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { useNewWorkspaceForm } from "./new-workspace/form"; import { ImportSessionButton } from "./new-workspace/import-session-button"; import { useImportSession } from "@/hooks/use-import-session"; import { @@ -132,12 +119,6 @@ import { import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; -const ThemedFolderPlus = withUnistyles(FolderPlus); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const addProjectIcon = ( - -); - function useIsNewWorkspaceDraftHandoffActive(input: { draftId: string | undefined; selectedServerId: string; @@ -198,504 +179,8 @@ const NO_TERMINAL_ATTACHMENTS: UserComposerAttachment[] = []; function noopChangeAttachments() {} function noopClearDraft() {} -const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; -const ThemedChevronDown = withUnistyles(ChevronDown); -const chevronExtraMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); - -// Every picker chip on this screen shares one chevron so they stay a single -// visual family. Extra-muted: the chevron is an affordance, not information, -// and it should sit behind the label it belongs to. -function MetaChevron(): ReactElement { - return ( - - - - ); -} - -const metaChevron = ; - // Stable reference so the keyboard-action handler doesn't re-register each render. const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"]; -// Height of a single picker-trigger badge. The Base-row spacer reserves exactly -// this so toggling Isolation to Local hides the row without shifting the form. -const BADGE_HEIGHT = 28; - -function RefPickerBadgeContent({ - selectedItem, - triggerLabel, - iconColor, - iconSize, -}: { - selectedItem: PickerItem | null; - triggerLabel: string; - iconColor: string; - iconSize: number; -}) { - return ( - <> - - {selectedItem?.kind === "github-pr" ? ( - - ) : ( - - )} - - - {triggerLabel} - - - ); -} - -function RefPickerTrigger({ - pickerAnchorRef, - onPress, - disabled, - badgePressableStyle, - selectedItem, - triggerLabel, - accessibilityLabel, - tooltipLabel, - iconColor, - iconSize, -}: { - pickerAnchorRef: React.RefObject; - onPress: () => void; - disabled: boolean; - badgePressableStyle: React.ComponentProps["style"]; - selectedItem: PickerItem | null; - triggerLabel: string; - accessibilityLabel: string; - tooltipLabel: string; - iconColor: string; - iconSize: number; -}) { - return ( - - - - - - - - {tooltipLabel} - - - ); -} - -function ProjectPickerTrigger({ - pickerAnchorRef, - onPress, - disabled, - badgePressableStyle, - label, - tooltipLabel, - projectViewKey, - iconDataUri, - iconColor, - iconSize, -}: { - pickerAnchorRef: React.RefObject; - onPress: () => void; - disabled: boolean; - badgePressableStyle: React.ComponentProps["style"]; - label: string; - tooltipLabel: string; - projectViewKey: string | null; - iconDataUri: string | null; - iconColor: string; - iconSize: number; -}) { - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; - return ( - - - - - {projectViewKey ? ( - - ) : ( - - )} - - - {label} - - - - - {tooltipLabel} - - - ); -} - -function PickerOptionItem({ - testID, - label, - description, - selected, - active, - disabled, - onPress, - isBranch, - trailingLabel, - accessibilityLabel, - iconColor, - iconSize, -}: { - testID: string; - label: string; - description: string | undefined; - selected: boolean; - active: boolean; - disabled: boolean; - onPress: () => void; - isBranch: boolean; - trailingLabel?: string; - accessibilityLabel?: string; - iconColor: string; - iconSize: number; -}) { - const leadingSlot = useMemo( - () => ( - - {isBranch ? ( - - ) : ( - - )} - - ), - [isBranch, iconSize, iconColor], - ); - const trailingSlot = useMemo( - () => - trailingLabel ? {trailingLabel} : undefined, - [trailingLabel], - ); - return ( - - ); -} - -function IsolationOptionItem({ - optionId, - label, - selected, - active, - disabled, - onPress, - iconColor, - iconSize, -}: { - optionId: string; - label: string; - selected: boolean; - active: boolean; - disabled: boolean; - onPress: () => void; - iconColor: string; - iconSize: number; -}) { - const leadingSlot = useMemo( - () => ( - - {optionId === "worktree" ? ( - - ) : ( - - )} - - ), - [optionId, iconSize, iconColor], - ); - return ( - - ); -} - -function ProjectOptionItem({ - testID, - projectViewKey, - iconDataUri, - label, - description, - selected, - active, - disabled, - onPress, -}: { - testID: string; - projectViewKey: string; - iconDataUri: string | null; - label: string; - description: string | undefined; - selected: boolean; - active: boolean; - disabled: boolean; - onPress: () => void; -}) { - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; - const leadingSlot = useMemo( - () => ( - - - - ), - [iconDataUri, placeholderInitial, projectViewKey], - ); - - return ( - - ); -} - -function NewWorkspacePickerOption({ - option, - selected, - active, - onPress, - itemById, - isPending, -}: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - itemById: Map; - isPending: boolean; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const item = itemById.get(option.id); - if (!item) return ; - - const isBranch = item.kind === "branch"; - const testID = isBranch - ? `new-workspace-ref-picker-branch-${item.name}` - : `new-workspace-ref-picker-pr-${item.item.number}`; - const description = - !isBranch && item.item.baseRefName - ? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName }) - : undefined; - - return ( - - ); -} - -function NewWorkspaceProjectPickerOption({ - option, - selected, - active, - onPress, - projectByOptionId, - projectIconDataByProjectViewKey, - selectedServerId, - isPending, - supportsWorkspaceMultiplicity, -}: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - projectByOptionId: Map; - projectIconDataByProjectViewKey: Map; - selectedServerId: string; - isPending: boolean; - supportsWorkspaceMultiplicity: boolean; -}) { - const project = projectByOptionId.get(option.id); - if (!project) return ; - const sourceDirectory = - getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir; - - return ( - host.worktreeSupport !== "unsupported")) - } - onPress={onPress} - /> - ); -} - -function AddProjectPickerAction({ onPress }: { onPress: () => void }) { - const { t } = useTranslation(); - const openProjectKeys = useShortcutKeys("new-agent"); - const shortcut = useMemo( - () => (openProjectKeys ? : null), - [openProjectKeys], - ); - - return ( - - ); -} - -function newWorkspaceHostOptionTestID(serverId: string): string { - return `new-workspace-host-picker-option-${serverId}`; -} - -function IsolationPickerTrigger({ - pickerAnchorRef, - onPress, - disabled, - badgePressableStyle, - isolation, - label, - tooltipLabel, - iconColor, - iconSize, -}: { - pickerAnchorRef: React.RefObject; - onPress: () => void; - disabled: boolean; - badgePressableStyle: React.ComponentProps["style"]; - isolation: "local" | "worktree"; - label: string; - tooltipLabel: string; - iconColor: string; - iconSize: number; -}) { - return ( - - - - - {isolation === "worktree" ? ( - - ) : ( - - )} - - - {label} - - - - - {tooltipLabel} - - - ); -} - -// Wraps a single argument control in the mobile vertical stack. On desktop the -// controls are laid out in one horizontal row, so no per-control wrapper is used. -// Decorative padding must not block the dock background from iOS hit testing. -function FormRow({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} interface WorkspaceIsolationState { isolation: "local" | "worktree"; @@ -738,12 +223,6 @@ function useWorkspaceIsolation(input: { }; } -function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string { - return isolation === "worktree" - ? t("newWorkspace.isolation.worktree") - : t("newWorkspace.isolation.local"); -} - function normalizeBranchDetails( data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined, ): BranchPickerDetail[] { @@ -1367,264 +846,6 @@ function useNewWorkspaceInitialContext({ }; } -type RefPickerRenderOption = NonNullable; - -interface FormPickerControl { - anchorRef: RefObject; - open: () => void; - openState: boolean; - onOpenChange: (open: boolean) => void; -} - -interface NewWorkspaceFormStackInput { - isCompact: boolean; - isPending: boolean; - project: FormPickerControl & { - options: ComboboxOptionType[]; - triggerLabel: string; - selectedProject: HostProjectListItem | null; - iconDataByProjectViewKey: Map; - selectedOptionId: string; - onSelect: (id: string) => void; - onAddProject: () => void; - renderOption: RefPickerRenderOption; - }; - host: FormPickerControl & { - allHosts: HostProfile[]; - selectedServerId: string; - onSelect: (id: string) => void; - }; - isolation: FormPickerControl & { - effectiveIsolation: "local" | "worktree"; - options: ComboboxOptionType[]; - onSelect: (id: string) => void; - renderOption: RefPickerRenderOption; - canCreateWorktree: boolean; - }; - base: FormPickerControl & { - selectedSourceDirectory: string | null; - selectedItem: PickerItem | null; - triggerLabel: string; - options: ComboboxOptionType[]; - selectedOptionId: string; - onSelect: (id: string) => void; - setSearchQuery: (query: string) => void; - emptyText: string; - renderOption: RefPickerRenderOption; - showRefPicker: boolean; - }; - launch: { - serverId: string; - target: LaunchTarget; - onChange: (target: LaunchTarget) => void; - profiles: readonly TerminalProfile[]; - disabled: boolean; - }; -} - -function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactElement { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const { isCompact, isPending, project, host, isolation, base, launch } = input; - - const selectedHostLabel = - host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; - const showHostControl = host.allHosts.length > 1; - const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); - const addProjectAction = useMemo( - () => , - [project.onAddProject], - ); - - const badgePressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.badge, - Boolean(hovered) && !isPending && styles.badgeHovered, - pressed && !isPending && styles.badgePressed, - isPending && styles.badgeDisabled, - ], - [isPending], - ); - - const desktopControlStyle = isCompact ? undefined : styles.desktopControl; - - const projectControl = ( - - - - - ); - - const hostControl = showHostControl ? ( - - - - - - - - - - {selectedHostLabel} - - {metaChevron} - - - - {t("newWorkspace.tooltips.host")} - - - - - ) : null; - - const isolationControl = isolation.canCreateWorktree ? ( - - - - - ) : null; - - const baseControl = base.showRefPicker ? ( - - - - - ) : null; - - const launchControl = ( - - ); - - return isCompact ? ( - - {projectControl} - {hostControl ? {hostControl} : null} - {isolationControl ? {isolationControl} : null} - {baseControl ? {baseControl} : null} - {launchControl} - {/* Keep fixed stack height without separating the visible controls. */} - {isolationControl ? null : } - {baseControl ? null : } - - ) : ( - - {projectControl} - {hostControl} - {isolationControl} - {baseControl} - - {launchControl} - - ); -} - export function NewWorkspaceScreen({ serverId, sourceDirectory: sourceDirectoryProp, @@ -1633,7 +854,6 @@ export function NewWorkspaceScreen({ draftId, }: NewWorkspaceScreenProps) { const queryClient = useQueryClient(); - const { theme } = useUnistyles(); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); @@ -1946,6 +1166,10 @@ export function NewWorkspaceScreen({ openAddProjectPicker(selectedServerId); }, [openAddProjectPicker, selectedServerId]); + const handleAddHost = useCallback(() => { + router.push(buildSettingsAddHostRoute(Date.now())); + }, []); + const openPicker = useCallback(() => { setPickerOpen(true); }, []); @@ -1978,50 +1202,6 @@ export function NewWorkspaceScreen({ setIsolationPickerOpen(nextOpen); }, []); - // "New worktree" is omitted entirely (not disabled) when the project isn't a - // git checkout, since worktree isolation is impossible there. - const isolationOptions = useMemo(() => { - const localOption = { id: "local", label: isolationLabel(t, "local") }; - if (!canCreateWorktree) return [localOption]; - return [localOption, { id: "worktree", label: isolationLabel(t, "worktree") }]; - }, [canCreateWorktree, t]); - - const handleSelectIsolationOption = useCallback( - (id: string) => { - setIsolation(id === "worktree" ? "worktree" : "local"); - setIsolationPickerOpen(false); - }, - [setIsolation], - ); - - const renderIsolationOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - }) => { - return ( - - ); - }, - [isPending, theme.colors.foregroundMuted, theme.iconSize.sm], - ); - const handleClearDraft = useCallback(() => { // No-op: screen navigates away on success, text should stay for retry on error }, []); @@ -2261,41 +1441,6 @@ export function NewWorkspaceScreen({ withConnectedClient, ]); - const renderPickerOption = useCallback( - (props: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - }) => , - [isPending, itemById], - ); - - const renderProjectOption = useCallback( - (props: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ( - - ), - [ - isPending, - projectByOptionId, - projectIconDataByProjectViewKey, - selectedServerId, - supportsWorkspaceMultiplicity, - ], - ); - const agentControlsWithDisabled = useMemo( () => composerState @@ -2312,57 +1457,58 @@ export function NewWorkspaceScreen({ ? t("newWorkspace.refPicker.searching") : t("newWorkspace.refPicker.noMatchingRefs"); - const formStack = useNewWorkspaceFormStack({ + const formStack = useNewWorkspaceForm({ isCompact, isPending, project: { anchorRef: projectPickerAnchorRef, open: openProjectPicker, - options: projectPickerOptions, - triggerLabel: projectTriggerLabel, - selectedProject, - iconDataByProjectViewKey: projectIconDataByProjectViewKey, - selectedOptionId: selectedProjectOptionId, - onSelect: handleSelectProjectOption, - onAddProject: handleAddProject, openState: projectPickerOpen, onOpenChange: handleProjectPickerOpenChange, - renderOption: renderProjectOption, + options: projectPickerOptions, + projectByOptionId, + iconDataByProjectViewKey: projectIconDataByProjectViewKey, + selectedProject, + selectedOptionId: selectedProjectOptionId, + triggerLabel: projectTriggerLabel, + selectedServerId, + supportsWorkspaceMultiplicity, + onSelect: handleSelectProjectOption, + onAddProject: handleAddProject, }, host: { + anchorRef: hostPickerAnchorRef, + open: openHostPicker, + openState: hostPickerOpen, + onOpenChange: handleHostPickerOpenChange, allHosts, selectedServerId, onSelect: handleSelectWorkspaceHost, - openState: hostPickerOpen, - onOpenChange: handleHostPickerOpenChange, - anchorRef: hostPickerAnchorRef, - open: openHostPicker, + onAddHost: handleAddHost, }, isolation: { anchorRef: isolationPickerAnchorRef, open: openIsolationPicker, - effectiveIsolation, - options: isolationOptions, - onSelect: handleSelectIsolationOption, openState: isolationPickerOpen, onOpenChange: handleIsolationPickerOpenChange, - renderOption: renderIsolationOption, + effectiveIsolation, canCreateWorktree, + onSelect: setIsolation, }, base: { anchorRef: pickerAnchorRef, open: openPicker, + openState: pickerOpen, + onOpenChange: handlePickerOpenChange, selectedSourceDirectory, selectedItem, triggerLabel, options, + itemById, selectedOptionId, onSelect: handleSelectOption, - openState: pickerOpen, - onOpenChange: handlePickerOpenChange, setSearchQuery: setPickerSearchQuery, emptyText: pickerEmptyText, - renderOption: renderPickerOption, showRefPicker, }, launch: { @@ -2491,11 +1637,17 @@ function NewWorkspaceLayout({ onImportSession: () => void; children: ReactNode; }) { - // At the top of the screen on compact layouts, under the composer otherwise. + // In the header on compact layouts, under the composer otherwise. const importSessionButton = ; - const setupFields = ( + // Compact has no title: the fields name themselves. The free space above pushes them down onto + // the composer, where the thumb is. + const setupFields = isCompact ? ( + <> + + {formStack} + + ) : ( <> - {isCompact ? {importSessionButton} : null} {title} @@ -2504,7 +1656,9 @@ function NewWorkspaceLayout({ ); return ( <> - {fullscreen ? null : } + {fullscreen ? null : ( + + )} @@ -2529,11 +1683,8 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", flex: 1, }, - // Takes the free space above the setup fields, so its button sits at the top of the screen. - // The inset puts the ghost button's icon on the setup rows' icon rail. - compactTopActions: { + compactSetupSpace: { flex: 1, - paddingHorizontal: theme.spacing[3], }, composerTitleContainer: { marginBottom: theme.spacing[8], @@ -2550,105 +1701,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, lineHeight: 20, }, - formStack: { - marginBottom: theme.spacing[8], - gap: theme.spacing[2], - }, - formStackDesktop: { - flexDirection: "row", - alignItems: "center", - // Matches the gap between the composer and the Import session pill below it. - marginBottom: theme.spacing[4], - // The badge adds its own left padding; offset it so the project icon's left - // edge lands exactly on the "New workspace" title's left edge. The trailing - // inset mirrors it so the launch chip stops on the composer's inner content - // rather than running out to the composer's border. - paddingLeft: theme.spacing[4], - paddingRight: theme.spacing[4], - gap: theme.spacing[2], - }, - desktopControl: { - minWidth: 0, - flexShrink: 1, - }, - // The row's left inset matches the heading's text x (composerTitleContainer - // paddingLeft) so the control aligns with the "New workspace" glyph. The badge - // adds its own left padding, so the row inset is reduced by that amount. - row: { - flexDirection: "row", - alignItems: "center", - paddingLeft: theme.spacing[4], - gap: theme.spacing[1], - }, - baseSpacer: { - height: BADGE_HEIGHT, - }, - // Pushes the launch control to the trailing edge of the desktop meta row, - // next to project/host/branch. The row's own right inset (formStackDesktop) - // lands it on the composer's inner content, matching the left chips. - launchSpacer: { - flex: 1, - }, - badge: { - flexDirection: "row", - alignItems: "center", - height: BADGE_HEIGHT, - maxWidth: 240, - overflow: "hidden", - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - gap: theme.spacing[1], - }, - badgeHovered: { - backgroundColor: theme.colors.surface2, - }, - badgePressed: { - backgroundColor: theme.colors.surface0, - }, - badgeDisabled: { - opacity: 0.6, - }, - badgeText: { - minWidth: 0, - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - flexShrink: 1, - }, - tooltipText: { - fontSize: theme.fontSize.base, - color: theme.colors.popoverForeground, - }, - refDivergenceLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - fontVariant: ["tabular-nums"], - }, - chevronContainer: { - flexShrink: 0, - transform: [{ translateY: 1 }], - }, - badgeIconBox: { - width: theme.iconSize.md, - height: theme.iconSize.md, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - projectIconFallbackText: { - // Single uppercase initial inside an iconSize.md (16px) square — below the - // smallest font-size token, so it stays a literal sized to the box. - fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE, - fontWeight: "600", - }, - rowIconBox: { - width: theme.iconSize.md, - height: theme.iconSize.md, - alignItems: "center", - justifyContent: "center", - }, - hostStatusDot: { - width: 8, - height: 8, - borderRadius: 4, - }, })); diff --git a/packages/app/src/screens/new-workspace/form/field-trigger.tsx b/packages/app/src/screens/new-workspace/form/field-trigger.tsx new file mode 100644 index 0000000000..c82cadb14a --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/field-trigger.tsx @@ -0,0 +1,217 @@ +import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ChevronDown } from "lucide-react-native"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * `badge` is the desktop meta row: a borderless pill that names its field in a tooltip. + * `field` is the compact form: a full-width filled box with the field's label inside, above the + * value, so the whole field is one tap target. + */ +export type FieldTriggerVariant = "badge" | "field"; + +const ThemedChevronDown = withUnistyles(ChevronDown); +const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); + +// Extra-muted: the chevron is an affordance, not information, and sits behind its label. +function Chevron(): ReactElement { + return ( + + + + ); +} + +const chevron = ; + +export interface FieldTriggerContentProps { + variant: FieldTriggerVariant; + /** The field's name, shown inside a `field`. */ + label: string; + icon: ReactNode; + value: string; + /** Secondary text after the value, such as a terminal profile's name. */ + detail?: string; +} + +export function FieldTriggerContent({ + variant, + label, + icon, + value, + detail, +}: FieldTriggerContentProps): ReactElement { + const valueLine = ( + <> + {icon} + + {value} + {detail ? {detail} : null} + + + ); + if (variant === "badge") { + return ( + <> + {valueLine} + {chevron} + + ); + } + return ( + <> + + + {label} + + {valueLine} + + {chevron} + + ); +} + +/** Pressable style for a field trigger, for triggers that bring their own Pressable. */ +export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) { + return useCallback( + (state: PressableStateCallbackType & { hovered?: boolean }) => [ + variant === "field" ? styles.field : styles.badge, + !disabled && Boolean(state.hovered) && styles.highlighted, + !disabled && + state.pressed && + (variant === "field" ? styles.highlighted : styles.badgePressed), + disabled && styles.disabled, + ], + [disabled, variant], + ); +} + +export interface FieldTriggerProps extends FieldTriggerContentProps { + /** What a screen reader announces; defaults to `label`. */ + accessibilityLabel?: string; + onPress: () => void; + disabled?: boolean; + /** Desktop hover hint; badges only, since a field shows its label. */ + tooltip?: string; + testID?: string; +} + +/** A field trigger that anchors a Combobox. */ +export const FieldTrigger = forwardRef(function FieldTrigger( + { accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content }, + ref, +): ReactElement { + const style = useFieldTriggerStyle(content.variant, disabled); + const trigger = ( + + + + ); + if (content.variant === "field" || !tooltip) return trigger; + return ( + + + {trigger} + + + {tooltip} + + + ); +}); + +// Height of a badge. The desktop row sizes from it. +export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight; +// Height of a compact field: label line plus value line. The compact stack reserves rows from it. +export const FIELD_HEIGHT = 56; + +const styles = StyleSheet.create((theme) => ({ + badge: { + flexDirection: "row", + alignItems: "center", + height: FIELD_BADGE_HEIGHT, + maxWidth: 240, + overflow: "hidden", + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + gap: theme.spacing[1], + }, + field: { + flexDirection: "row", + alignItems: "center", + height: FIELD_HEIGHT, + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[3], + borderRadius: theme.borderRadius.xl, + backgroundColor: theme.colors.surface1, + gap: theme.spacing[2], + }, + highlighted: { + backgroundColor: theme.colors.surface2, + }, + badgePressed: { + backgroundColor: theme.colors.surface0, + }, + disabled: { + opacity: 0.6, + }, + // Optical: the chevron's ink sits high in its box. + chevron: { + flexShrink: 0, + transform: [{ translateY: 1 }], + }, + iconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + value: { + minWidth: 0, + flexShrink: 1, + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + }, + fieldBody: { + flex: 1, + minWidth: 0, + gap: theme.spacing[0.5], + }, + fieldLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + fieldValueLine: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, + fieldValue: { + minWidth: 0, + flexShrink: 1, + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + }, + detail: { + color: theme.colors.foregroundExtraMuted, + }, + tooltipText: { + fontSize: theme.fontSize.base, + color: theme.colors.popoverForeground, + }, +})); diff --git a/packages/app/src/screens/new-workspace/form/icons.tsx b/packages/app/src/screens/new-workspace/form/icons.tsx new file mode 100644 index 0000000000..9fa277542d --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/icons.tsx @@ -0,0 +1,49 @@ +import type { ReactElement } from "react"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; + +const ThemedFolder = withUnistyles(Folder); +const ThemedFolderPlus = withUnistyles(FolderPlus); +const ThemedGitBranch = withUnistyles(GitBranch); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export const folderIcon = ; +export const addProjectIcon = ; +export const branchIcon = ; +export const pullRequestIcon = ; + +// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size +// token, so it stays a literal sized to the box. +const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; + +export function ProjectIcon({ + projectViewKey, + projectName, + iconDataUri, +}: { + projectViewKey: string; + projectName: string; + iconDataUri: string | null; +}): ReactElement { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + fallbackText: { + fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE, + fontWeight: theme.fontWeight.semibold, + }, +})); diff --git a/packages/app/src/screens/new-workspace/form/index.tsx b/packages/app/src/screens/new-workspace/form/index.tsx new file mode 100644 index 0000000000..d05adb2e61 --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/index.tsx @@ -0,0 +1,392 @@ +import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; +import { HostStatusDot } from "@/components/host-status-dot"; +import { HostPicker } from "@/components/hosts/host-picker"; +import type { LaunchTarget } from "@/new-workspace-launch/target"; +import type { HostProjectListItem } from "@/projects/host-projects"; +import type { HostProfile } from "@/types/host-connection"; +import type { PickerItem } from "../../new-workspace-picker-item"; +import { + FIELD_BADGE_HEIGHT, + FIELD_HEIGHT, + FieldTrigger, + type FieldTriggerVariant, +} from "./field-trigger"; +import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons"; +import { LaunchControl } from "./launch-control"; +import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options"; + +type Isolation = "local" | "worktree"; +type RenderOption = NonNullable; + +interface PickerControl { + anchorRef: RefObject; + open: () => void; + openState: boolean; + onOpenChange: (open: boolean) => void; +} + +export interface NewWorkspaceFormProps { + isCompact: boolean; + isPending: boolean; + project: PickerControl & { + options: ComboboxOption[]; + projectByOptionId: Map; + iconDataByProjectViewKey: Map; + selectedProject: HostProjectListItem | null; + selectedOptionId: string; + triggerLabel: string; + selectedServerId: string; + /** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */ + supportsWorkspaceMultiplicity: boolean; + onSelect: (id: string) => void; + onAddProject: () => void; + }; + host: PickerControl & { + allHosts: HostProfile[]; + selectedServerId: string; + onSelect: (id: string) => void; + onAddHost: () => void; + }; + isolation: PickerControl & { + effectiveIsolation: Isolation; + canCreateWorktree: boolean; + onSelect: (isolation: Isolation) => void; + }; + base: PickerControl & { + selectedSourceDirectory: string | null; + selectedItem: PickerItem | null; + triggerLabel: string; + options: ComboboxOption[]; + itemById: Map; + selectedOptionId: string; + onSelect: (id: string) => void; + setSearchQuery: (query: string) => void; + emptyText: string; + showRefPicker: boolean; + }; + launch: { + serverId: string; + target: LaunchTarget; + onChange: (target: LaunchTarget) => void; + profiles: readonly TerminalProfile[]; + disabled: boolean; + }; +} + +function hostOptionTestID(serverId: string): string { + return `new-workspace-host-picker-option-${serverId}`; +} + +function isProjectSelectable( + project: HostProjectListItem, + supportsWorkspaceMultiplicity: boolean, +): boolean { + return ( + supportsWorkspaceMultiplicity || + project.hosts.some((host) => host.worktreeSupport !== "unsupported") + ); +} + +function useProjectPicker( + project: NewWorkspaceFormProps["project"], + isPending: boolean, +): { renderOption: RenderOption; footer: ReactNode } { + const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project; + const { supportsWorkspaceMultiplicity, onAddProject } = project; + const renderOption = useCallback( + (row) => { + const item = projectByOptionId.get(row.option.id); + return ( + + ); + }, + [ + iconDataByProjectViewKey, + isPending, + projectByOptionId, + selectedServerId, + supportsWorkspaceMultiplicity, + ], + ); + const footer = useMemo(() => , [onAddProject]); + return { renderOption, footer }; +} + +function useIsolationPicker( + isolation: NewWorkspaceFormProps["isolation"], + isPending: boolean, +): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } { + const { t } = useTranslation(); + const { canCreateWorktree, onSelect, onOpenChange } = isolation; + // "New worktree" is omitted entirely (not disabled) when the project isn't a + // git checkout, since worktree isolation is impossible there. + const options = useMemo(() => { + const local = { id: "local", label: t("newWorkspace.isolation.local") }; + if (!canCreateWorktree) return [local]; + return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }]; + }, [canCreateWorktree, t]); + const handleSelect = useCallback( + (id: string) => { + onSelect(id === "worktree" ? "worktree" : "local"); + onOpenChange(false); + }, + [onOpenChange, onSelect], + ); + const renderOption = useCallback( + (row) => , + [isPending], + ); + return { options, onSelect: handleSelect, renderOption }; +} + +function useRefRenderOption(itemById: Map, isPending: boolean): RenderOption { + return useCallback( + (row) => , + [isPending, itemById], + ); +} + +/** + * The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack + * of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field + * that does not apply to the selected project is left out, and Host only shows with a second host. + */ +export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement { + const { t } = useTranslation(); + const { isCompact, isPending, project, host, isolation, base, launch } = props; + const variant: FieldTriggerVariant = isCompact ? "field" : "badge"; + const projectPicker = useProjectPicker(project, isPending); + const isolationPicker = useIsolationPicker(isolation, isPending); + const renderRefOption = useRefRenderOption(base.itemById, isPending); + + const showHost = host.allHosts.length > 1; + const selectedHostLabel = + host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; + const selectedProject = project.selectedProject; + const selectedProjectIconUri = selectedProject + ? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null) + : null; + const projectIcon = useMemo( + () => + selectedProject ? ( + + ) : ( + folderIcon + ), + [selectedProject, selectedProjectIconUri], + ); + const hostIcon = useMemo( + () => , + [host.selectedServerId], + ); + const isWorktree = isolation.effectiveIsolation === "worktree"; + const isolationValue = isWorktree + ? t("newWorkspace.isolation.worktree") + : t("newWorkspace.isolation.local"); + const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon; + + const projectField = ( + + ); + const hostField = showHost ? ( + + + + ) : null; + const isolationField = isolation.canCreateWorktree ? ( + + ) : null; + const baseField = base.showRefPicker ? ( + + ) : null; + const launchField = ( + + ); + + const pickers = ( + <> + + + + + ); + + if (isCompact) { + return ( + + {projectField} + {hostField} + {isolationField} + {baseField} + {launchField} + {/* Isolation and Base come and go with the project; their rows stay reserved so the + stack keeps one height and nothing above the composer jumps. */} + {isolationField ? null : } + {baseField ? null : } + {pickers} + + ); + } + + return ( + + {projectField} + {hostField ? {hostField} : null} + {isolationField ? {isolationField} : null} + {baseField ? {baseField} : null} + + {launchField} + {pickers} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compactStack: { + marginBottom: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[2], + }, + reservedField: { + height: FIELD_HEIGHT, + }, + desktopRow: { + flexDirection: "row", + alignItems: "center", + minHeight: FIELD_BADGE_HEIGHT, + // Matches the gap between the composer and the Import session pill below it. + marginBottom: theme.spacing[4], + // The badge adds its own left padding; offset it so the project icon's left + // edge lands exactly on the "New workspace" title's left edge. The trailing + // inset mirrors it so the launch chip stops on the composer's inner content + // rather than running out to the composer's border. + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[2], + }, + desktopControl: { + minWidth: 0, + flexShrink: 1, + }, + // Pushes the launch control to the trailing edge of the desktop meta row. + launchSpacer: { + flex: 1, + }, +})); diff --git a/packages/app/src/new-workspace-launch/launch-control.tsx b/packages/app/src/screens/new-workspace/form/launch-control.tsx similarity index 79% rename from packages/app/src/new-workspace-launch/launch-control.tsx rename to packages/app/src/screens/new-workspace/form/launch-control.tsx index 785cfb56ac..fb6af66022 100644 --- a/packages/app/src/new-workspace-launch/launch-control.tsx +++ b/packages/app/src/screens/new-workspace/form/launch-control.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo, useState, type ReactElement } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { useRouter } from "expo-router"; -import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native"; +import { MessageCircle, SquareTerminal } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { DropdownMenu, @@ -30,14 +30,17 @@ import { resolveLaunchProfile, terminalLaunchTarget, type LaunchTarget, -} from "./target"; +} from "@/new-workspace-launch/target"; +import { + FieldTriggerContent, + useFieldTriggerStyle, + type FieldTriggerVariant, +} from "./field-trigger"; const ThemedMessageCircle = withUnistyles(MessageCircle); const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); const chatIcon = ; const blankTerminalIcon = ; @@ -83,12 +86,7 @@ export interface LaunchControlProps { onChange: (target: LaunchTarget) => void; profiles: readonly TerminalProfile[]; disabled?: boolean; - /** - * The meta row's shared badge style. Passed in rather than redeclared so this - * trigger stays pixel-identical to the project, host, and branch triggers it - * sits beside; a local copy drifts the moment one of them changes. - */ - badgePressableStyle: React.ComponentProps["style"]; + variant: FieldTriggerVariant; } function TriggerIcon({ @@ -108,8 +106,8 @@ function TriggerIcon({ } /** - * The meta-row control choosing what New workspace submits to: the chat agent, - * or a terminal. One trigger shaped like every other chip on that row, opening + * The form control choosing what New workspace submits to: the chat agent, + * or a terminal. One trigger shaped like every other field trigger, opening * a menu whose Chat and Terminal groups are what keep a terminal profile named * "Codex" from reading as a chat Codex agent. */ @@ -119,13 +117,18 @@ export function LaunchControl({ onChange, profiles, disabled = false, - badgePressableStyle, + variant, }: LaunchControlProps) { const { t } = useTranslation(); const router = useRouter(); const [open, setOpen] = useState(false); const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]); + const triggerStyle = useFieldTriggerStyle(variant, disabled); + const triggerIcon = useMemo( + () => , + [selectedProfile, target], + ); // A profile's own name and logo say nothing about where it runs: a row // reading "(openai logo) Codex" is indistinguishable from a chat Codex agent. @@ -149,32 +152,26 @@ export function LaunchControl({ return ( - {/* The trigger is the Pressable itself, styled by the meta row's shared - badge style, so it lands pixel-identical to the project, host, and - branch chips it sits beside. */} {/* MenuTrigger is not a forwardRef component and pins its own ref, so the tooltip anchors to a wrapping View instead. Suppressed while the menu is open so the two surfaces never overlap. */} - + - - - - - {triggerLabel} - {selectedProfile ? ( - {selectedProfile.name} - ) : null} - - + @@ -232,27 +229,10 @@ export function LaunchControl({ } const styles = StyleSheet.create((theme) => ({ - iconBox: { - width: theme.iconSize.md, - height: theme.iconSize.md, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - label: { - minWidth: 0, - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - flexShrink: 1, - }, tooltipText: { fontSize: theme.fontSize.base, color: theme.colors.popoverForeground, }, - profileName: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundExtraMuted, - }, command: { fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/new-workspace/form/options.tsx b/packages/app/src/screens/new-workspace/form/options.tsx new file mode 100644 index 0000000000..88f5c5d4dd --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/options.tsx @@ -0,0 +1,166 @@ +import { useMemo, type ReactElement } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects"; +import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item"; +import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons"; + +interface OptionRowState { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; +} + +function IconBox({ children }: { children: ReactElement }): ReactElement { + return {children}; +} + +export function ProjectOption({ + option, + selected, + active, + onPress, + project, + iconDataUri, + selectedServerId, + disabled, +}: OptionRowState & { + project: HostProjectListItem | undefined; + iconDataUri: string | null; + selectedServerId: string; + disabled: boolean; +}): ReactElement { + const leadingSlot = useMemo( + () => + project ? ( + + + + ) : undefined, + [iconDataUri, project], + ); + if (!project) return ; + return ( + + ); +} + +export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement { + const { t } = useTranslation(); + const openProjectKeys = useShortcutKeys("new-agent"); + const shortcut = useMemo( + () => (openProjectKeys ? : null), + [openProjectKeys], + ); + return ( + + ); +} + +const worktreeLeading = {branchIcon}; +const localLeading = {folderIcon}; + +export function IsolationOption({ + option, + selected, + active, + onPress, + disabled, +}: OptionRowState & { disabled: boolean }): ReactElement { + return ( + + ); +} + +const branchLeading = {branchIcon}; +const pullRequestLeading = {pullRequestIcon}; + +export function RefOption({ + option, + selected, + active, + onPress, + item, + disabled, +}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement { + const { t } = useTranslation(); + const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined; + const trailingSlot = useMemo( + () => + divergenceLabel ? {divergenceLabel} : undefined, + [divergenceLabel], + ); + if (!item) return ; + + const isBranch = item.kind === "branch"; + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + iconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + }, + divergenceLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + fontVariant: ["tabular-nums"], + }, +})); diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx index 39b55e1b2e..39d4efc1ce 100644 --- a/packages/app/src/screens/new-workspace/import-session-button.tsx +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; /** - * The new workspace screen's way into Import session: a ghost button at the top on compact + * The new workspace screen's way into Import session: a ghost button in the header on compact * layouts, an outlined pill under the composer otherwise. Both are muted until hovered. */ export function ImportSessionButton({ @@ -17,7 +17,7 @@ export function ImportSessionButton({ }) { const { t } = useTranslation(); return ( - +