mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 23:02:36 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79de2540fd | ||
|
|
213c78e819 | ||
|
|
941c890744 | ||
|
|
e109b866ab | ||
|
|
a4eb0adcf6 | ||
|
|
6ff071a487 | ||
|
|
ecd42015b9 | ||
|
|
86817a24fc | ||
|
|
86ab49153a | ||
|
|
6e5a4682ee | ||
|
|
f22775a8e1 |
@@ -405,9 +405,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
# Balance contiguous file groups using CI durations and the trimmed
|
||||
# inventory. Required check names and worker counts stay unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "187:183:184:201"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
|
||||
- Extract complex setup into reusable helpers
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
- Use one browser test per causal user journey, with named `test.step()` blocks for behaviors reached along the way. Reuse the open form, workspace, and browser state. Start another test for a distinct initial state, failure injection, race ordering, viewport, or destructive branch.
|
||||
- Keep pure test-helper checks in `packages/app/e2e/support/helpers/*.test.ts`; the app unit job runs them without Metro, a daemon, or a browser.
|
||||
|
||||
### File naming
|
||||
|
||||
|
||||
@@ -460,7 +460,7 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
|
||||
const target = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
|
||||
title: "Hidden streaming submission",
|
||||
model: "ten-second-stream",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
const evictionAgents = await Promise.all(
|
||||
Array.from({ length: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES }, (_unused, index) =>
|
||||
@@ -549,7 +549,7 @@ async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
|
||||
title: "Submission missed running transition",
|
||||
model: "ten-second-stream",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
|
||||
@@ -700,7 +700,7 @@ async function expectCanonicalOrderWinsAcrossOverlappingClients(
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
|
||||
title: "Cross-client submission order",
|
||||
model: "ten-second-stream",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
const localPrompt = "Send this after the other client turn.";
|
||||
const remotePrompt = "Commit this other client turn first.";
|
||||
|
||||
@@ -36,15 +36,25 @@ test.describe("Host agent skills", () => {
|
||||
page,
|
||||
skills,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const custom = skills.available.slice(0, 2);
|
||||
const cancelledAddition = skills.available[2]!;
|
||||
await openAgentSkillsSettings(page, skills);
|
||||
|
||||
await openSkillSelection(page);
|
||||
await test.step("the desktop sheet gives the bundled label its intended inset", async () => {
|
||||
await expectBundledSkillsInset(page);
|
||||
});
|
||||
await chooseCustomSkills(page, custom);
|
||||
await saveSkillSelection(page);
|
||||
await expectInstalledSkills(skills, custom);
|
||||
await expectSavedSelection(skills, { mode: "custom", skills: custom });
|
||||
await test.step("installation ignores agent config environment overrides", async () => {
|
||||
await expect(access(`${skills.home}/../ignored-claude-config/skills`)).rejects.toThrow(
|
||||
"ENOENT",
|
||||
);
|
||||
await expect(access(`${skills.home}/../ignored-codex-home/skills`)).rejects.toThrow("ENOENT");
|
||||
});
|
||||
|
||||
await openSkillSelection(page);
|
||||
await toggleSkill(page, cancelledAddition);
|
||||
@@ -150,23 +160,6 @@ test.describe("Host agent skills", () => {
|
||||
await expectInstalledSkills(skills, secondSelection.skills);
|
||||
await expectSavedSelection(skills, secondSelection);
|
||||
});
|
||||
|
||||
test("ignores agent config environment overrides", async ({ skills }) => {
|
||||
await skills.install({ mode: "custom", skills: [skills.available[0]!] });
|
||||
|
||||
await expect(
|
||||
access(`${skills.home}/../ignored-claude-config/skills`).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
access(`${skills.home}/../ignored-codex-home/skills`).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function allSkillsCard(page: Parameters<typeof openSkillSelection>[0]) {
|
||||
@@ -192,24 +185,14 @@ test.describe("Agent skills sheet inset", () => {
|
||||
await openSkillSelection(page);
|
||||
await waitForSettledPosition(page.getByTestId("skill-selection-sheet"));
|
||||
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("desktop", () => {
|
||||
test.use({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
test("keeps more space above the bundled skills label than below", async ({ page, skills }) => {
|
||||
await openAgentSkillsSettings(page, skills);
|
||||
await openSkillSelection(page);
|
||||
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
await expectBundledSkillsInset(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectBundledSkillsInset(page: Parameters<typeof openSkillSelection>[0]) {
|
||||
expect({
|
||||
aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)),
|
||||
belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)),
|
||||
}).toEqual({ aboveLabel: 16, belowLabel: 12 });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect } from "@playwright/test";
|
||||
import {
|
||||
findScrollJumps,
|
||||
expectImageSpaceReserved,
|
||||
type ScrollFrame,
|
||||
observeTimelinePages,
|
||||
openOnlyTimelineTail,
|
||||
recordUpwardTraversal,
|
||||
@@ -14,179 +12,6 @@ import {
|
||||
holdParagraphThroughCompletion,
|
||||
} from "../support/helpers/timeline-scroll-smoothness";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 1000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -150, height: 150 },
|
||||
{ id: "reading", top: 0, height: 100 },
|
||||
],
|
||||
anchor: "reading",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1400,
|
||||
scrollTop: 900,
|
||||
wheelTotal: 100,
|
||||
lastWheelAt: 1050,
|
||||
rows: [
|
||||
{ id: "image", top: -50, height: 1150 },
|
||||
{ id: "reading", top: 1100, height: 100 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, scrollTop: 200 },
|
||||
{
|
||||
...after,
|
||||
wheelTotal: 1000,
|
||||
scrollTop: 0,
|
||||
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
|
||||
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -520, height: 278 },
|
||||
{ id: "image", top: -242, height: 184 },
|
||||
{ id: "old-reader", top: -58, height: 130 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
|
||||
const after: ScrollFrame = {
|
||||
...wheel,
|
||||
at: 1220,
|
||||
scrollTop: 1520,
|
||||
scrollHeight: 5041,
|
||||
anchor: "new-reader",
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 225 },
|
||||
{ id: "old-reader", top: 463, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, wheel, after])).toEqual([]);
|
||||
// The previous 480 px input has already moved the viewport; the recent
|
||||
// budget includes it, but only the next 480 px belongs to this frame.
|
||||
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
|
||||
const steadyAfter = {
|
||||
...after,
|
||||
at: 1100,
|
||||
wheelTotal: 960,
|
||||
lastWheelAt: 1099,
|
||||
scrollHeight: 6000,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 1184 },
|
||||
{ id: "old-reader", top: 1422, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
|
||||
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
wheel,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
|
||||
for (const height of [225, 1632]) {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2208,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 516,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -704, height: 560 },
|
||||
{ id: "caption", top: -144, height: 100 },
|
||||
{ id: "old-reader", top: -44, height: 127 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 3,
|
||||
unmounted: 0,
|
||||
};
|
||||
const resize = height - 560;
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1060,
|
||||
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
|
||||
scrollTop: 1938,
|
||||
scrollHeight: 5000 + resize - 110,
|
||||
wheelTotal: 160,
|
||||
lastWheelAt: 1020,
|
||||
imageLoads: 1,
|
||||
rows: [
|
||||
{ id: "image", top: -544, height },
|
||||
{ id: "caption", top: 16 + resize, height: 100 },
|
||||
{ id: "old-reader", top: 116 + resize, height: 127 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
// Resizing must not excuse additional viewport movement.
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
for (const cadence of scrollCadences) {
|
||||
test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({
|
||||
page,
|
||||
|
||||
@@ -51,7 +51,9 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
test("non-UI archive prunes open tabs and keeps the session archived when reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
@@ -65,28 +67,51 @@ test.describe("Archive tab reconciliation", () => {
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await test.step("open both sessions in two pages", async () => {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
});
|
||||
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
|
||||
await test.step("prune the archived tab across both pages and reload", async () => {
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
|
||||
await test.step("reopen the archived session without unarchiving it", async () => {
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
await reloadWorkspace(passivePage, surviving.workspaceId);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
@@ -123,35 +148,4 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
await expectAgentTabActive(page, archived.id);
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ test.describe("Assistant fork menu", () => {
|
||||
await expectLiveAssistantText(page, visibleAfterFork);
|
||||
});
|
||||
|
||||
test("focuses a forked assistant turn in a new workspace draft tab", async ({
|
||||
test("focuses an existing-workspace fork draft and keeps its attachment after submission", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
@@ -101,55 +101,44 @@ test.describe("Assistant fork menu", () => {
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await test.step("complete a second assistant turn in the visible source tab", async () => {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await awaitAssistantMessage(page);
|
||||
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "false");
|
||||
await expectChatHistoryAttachment(page);
|
||||
});
|
||||
|
||||
test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-submit-",
|
||||
title: "Assistant fork tab submit",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab submit.",
|
||||
model: "ten-second-stream",
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await awaitAssistantMessage(page);
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await test.step("fork the latest turn into the selected draft tab", async () => {
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await expectChatHistoryAttachment(page);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
|
||||
await submitMessage(page, "");
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(agentTab).toHaveAttribute("aria-selected", "false");
|
||||
await expectChatHistoryAttachment(page);
|
||||
});
|
||||
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: "Chat history" }).last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
await test.step("submit that draft with its chat-history attachment", async () => {
|
||||
await submitMessage(page, "");
|
||||
|
||||
const userMessage = page
|
||||
.getByTestId("user-message")
|
||||
.filter({ hasText: "Chat history" })
|
||||
.last();
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userMessage).not.toContainText("Source agent:");
|
||||
});
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
|
||||
@@ -262,15 +262,54 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
test("clean Changes opens committed changes and navigates an empty comparison", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
await test.step("Changes opens the populated committed comparison for a clean checkout", async () => {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("an empty Changes comparison links to the populated comparison", async () => {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName(
|
||||
"committed-only.ts, +1, -0",
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("Jump to file stays out of the desktop diff and of an empty comparison", async () => {
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
@@ -297,29 +336,6 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
||||
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -335,223 +351,109 @@ test("Changes comparison controls the working diff and tree selection focuses it
|
||||
await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts");
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
test("horizontal diff scrolling preserves its header and offset across collapse", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
const fileRowBounds = await fileRow.boundingBox();
|
||||
expect(fileRowBounds).not.toBeNull();
|
||||
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1);
|
||||
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("canvas file headers select without toggling for context menu and long press", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const firstFile = page.getByTestId("diff-file-0-toggle");
|
||||
const deletedFile = page.getByTestId("diff-file-1-toggle");
|
||||
await deletedFile.click({ button: "right" });
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await longPressFileHeader(page, firstFile);
|
||||
await expect(firstFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
|
||||
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("every interactive file header has the same hover feedback", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
const wideLine = `export const wide = "${"wide".repeat(160)}";`;
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff(wideLine);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
await test.step("horizontal body scrolling never moves or repaints the canvas header", async () => {
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
await test.step("collapsing and expanding restores the real horizontal scroll offset", async () => {
|
||||
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
|
||||
await expect.poll(() => readHorizontalDiffOffset(page)).toBe(retainedOffset);
|
||||
});
|
||||
});
|
||||
|
||||
test("in-flow file headers move with the diff document", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
test("file headers scroll with the document and hand off without gaps", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
await test.step("in-flow file headers move with the diff document", async () => {
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return [14, 16].map(
|
||||
(y) =>
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
await test.step("the outgoing sticky header hands off without a gap or overlap", async () => {
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const secondTop = await second.evaluate((element) => {
|
||||
const scrollElement = element.closest<HTMLElement>('[data-testid="git-diff-scroll"]');
|
||||
if (!scrollElement) return Number.NaN;
|
||||
return (
|
||||
scrollElement.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top
|
||||
);
|
||||
});
|
||||
expect(secondTop).toBeGreaterThan(30);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop - 15);
|
||||
await expect(first).toBeAttached();
|
||||
await expect(second).toBeAttached();
|
||||
|
||||
const [scrollBounds, firstBounds, secondBounds] = await Promise.all([
|
||||
scroller.boundingBox(),
|
||||
first.boundingBox(),
|
||||
second.boundingBox(),
|
||||
]);
|
||||
if (!scrollBounds || !firstBounds || !secondBounds) {
|
||||
throw new Error("Sticky transition geometry is unavailable");
|
||||
}
|
||||
expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0);
|
||||
expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0);
|
||||
const transitionHits = await scroller.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const headerAt = (y: number) =>
|
||||
document
|
||||
.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y)
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null,
|
||||
);
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
?.closest<HTMLElement>("[data-diff-header]")?.dataset.diffHeaderPath ?? null;
|
||||
return [headerAt(14), headerAt(16)];
|
||||
});
|
||||
expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]);
|
||||
await testInfo.attach("canvas-header-sticky-handoff", {
|
||||
body: await page.getByTestId("git-diff-canvas-root").screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
await scroller.evaluate((element, scrollTop) => {
|
||||
element.scrollTop = scrollTop;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
}, secondTop);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await second.boundingBox();
|
||||
return bounds ? bounds.y - scrollBounds.y : Number.NaN;
|
||||
})
|
||||
.toBeCloseTo(0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts")))
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("changes tree aligns every file status after its diff stat", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeDeletedFile: true,
|
||||
includeUntrackedFile: true,
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
const addedRow = tree.getByTestId("diff-tree-file-2");
|
||||
const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" });
|
||||
await expect(modifiedStatus).toBeVisible();
|
||||
await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible();
|
||||
|
||||
const [statBounds, statusBounds] = await Promise.all([
|
||||
modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(),
|
||||
modifiedStatus.boundingBox(),
|
||||
]);
|
||||
if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds");
|
||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test("the scrolling diff lists files in changes tree order", async ({ page }) => {
|
||||
test("browse the ordered changes tree and recursively collapse its folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeNestedFolders: true,
|
||||
includeRootFileSortingFirst: true,
|
||||
@@ -559,72 +461,70 @@ test("the scrolling diff lists files in changes tree order", async ({ page }) =>
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
await test.step("the scrolling diff lists files in changes tree order", async () => {
|
||||
const tree = changesTree(page);
|
||||
const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]');
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const diffHeaders = panel.getByTestId(/^diff-file-\d+$/);
|
||||
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||
// Directories sort before files at every level, so the root note lands last
|
||||
// even though "a-root-note.txt" compares below "src/..." as a whole string.
|
||||
const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"];
|
||||
|
||||
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
});
|
||||
// Equal counts also prove no folder is collapsed: flattenDiffTree drops the
|
||||
// descendants of a collapsed folder, which would make the tree a subsequence
|
||||
// of the diff rather than a match, and mask a real ordering difference.
|
||||
await expect(treeNames).toHaveCount(expected.length);
|
||||
await expect(diffHeaders).toHaveCount(expected.length);
|
||||
await expect(treeNames).toHaveText(expected);
|
||||
const expectedHeaderNames = [
|
||||
"src/zz-folder/nested/changed.ts, +1, -1",
|
||||
"src/zz-folder/root.ts, +1, -1",
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
"a-root-note.txt, +1, -0",
|
||||
];
|
||||
for (const [index, accessibleName] of expectedHeaderNames.entries()) {
|
||||
await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName);
|
||||
}
|
||||
});
|
||||
|
||||
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await test.step("changes context menu recursively collapses descendant folders", async () => {
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
const rootRow = tree.getByTestId("diff-folder-src-toggle");
|
||||
const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true });
|
||||
await expect(rootRow).toHaveCSS("opacity", "1");
|
||||
await expect(rootLabel).toHaveCSS("opacity", "0.76");
|
||||
await rootRow.hover();
|
||||
await expect(rootLabel).toHaveCSS("opacity", "1");
|
||||
await page.mouse.move(0, 0);
|
||||
const nestedLabel = tree
|
||||
.getByTestId("diff-folder-src/zz-folder-toggle")
|
||||
.getByText("zz-folder", { exact: true });
|
||||
const [rootBounds, nestedBounds] = await Promise.all([
|
||||
rootLabel.boundingBox(),
|
||||
nestedLabel.boundingBox(),
|
||||
]);
|
||||
if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds");
|
||||
expect(nestedBounds.x - rootBounds.x).toBe(12);
|
||||
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
const rootRow = tree.getByTestId("diff-folder-src-toggle");
|
||||
const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true });
|
||||
await expect(rootRow).toHaveCSS("opacity", "1");
|
||||
await expect(rootLabel).toHaveCSS("opacity", "0.76");
|
||||
await rootRow.hover();
|
||||
await expect(rootLabel).toHaveCSS("opacity", "1");
|
||||
await page.mouse.move(0, 0);
|
||||
const nestedLabel = tree
|
||||
.getByTestId("diff-folder-src/zz-folder-toggle")
|
||||
.getByText("zz-folder", { exact: true });
|
||||
const [rootBounds, nestedBounds] = await Promise.all([
|
||||
rootLabel.boundingBox(),
|
||||
nestedLabel.boundingBox(),
|
||||
]);
|
||||
if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds");
|
||||
expect(nestedBounds.x - rootBounds.x).toBe(12);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||
await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||
await expect(tree.getByText("root.ts", { exact: true })).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||
|
||||
await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||
await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||
@@ -761,70 +661,25 @@ test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||
.toBe(AFTER);
|
||||
});
|
||||
|
||||
test("Changes keeps review navigation and controls inside its workspace tab", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
test("inspect, review, open, and duplicate changed files in the workspace", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
includeDeletedFile: true,
|
||||
includeUntrackedFile: true,
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await test.step("workspace controls update the comparison and presentation", async () => {
|
||||
await inspectChangesWorkspaceControls(page, workspace);
|
||||
});
|
||||
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: false });
|
||||
await scrollDiffToTop(page);
|
||||
await reviewChangedLine(page);
|
||||
await scrollDiffToTop(page);
|
||||
await inspectChangedFileHeaders(page);
|
||||
await test.step("duplicate the file and its folder from Changes", async () => {
|
||||
await page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).click();
|
||||
await duplicateChangedFiles(page, workspace);
|
||||
});
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
@@ -890,18 +745,6 @@ test("compact Changes jumps to a file from the changed-files sheet", async ({ pa
|
||||
});
|
||||
});
|
||||
|
||||
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
|
||||
const committed = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, committed, 90_000);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
|
||||
const explorer = await openCompactChanges(page, committed);
|
||||
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
|
||||
|
||||
await selectEmptyUncommittedComparison(page, explorer);
|
||||
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1013,33 +856,6 @@ test("changes diff waits for configured fonts, then applies code typography chan
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1058,21 +874,6 @@ test("split canvas creates a review on the changed side and keeps it in that col
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
|
||||
test("scrolling clears the hovered review affordance", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await hoverFirstChangedGutter(page);
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop += 80;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff uses the overlay scrollbar and its thumb controls vertical scrolling", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1120,33 +921,6 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5);
|
||||
|
||||
const affordance = page.getByRole("button", { name: "Add review comment" });
|
||||
await expect(affordance).toBeVisible();
|
||||
await expect(affordance.locator("svg")).toBeVisible();
|
||||
const affordanceBounds = await affordance.boundingBox();
|
||||
expect(affordanceBounds?.width).toBeCloseTo(22, 0);
|
||||
expect(affordanceBounds?.height).toBeCloseTo(22, 0);
|
||||
await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1297,23 +1071,6 @@ test("dragging within one wide grapheme or outside its cell never opens a review
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("collapsing and expanding restores the real horizontal scroll offset", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("x".repeat(400));
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-0-toggle").click();
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId("diff-file-0-horizontal-scroll").evaluate((element) => element.scrollLeft),
|
||||
)
|
||||
.toBe(retainedOffset);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
@@ -2218,3 +1975,242 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
});
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
}
|
||||
|
||||
async function readHorizontalDiffOffset(page: Page): Promise<number> {
|
||||
return page
|
||||
.getByTestId("diff-file-0-horizontal-scroll")
|
||||
.evaluate((element) => element.scrollLeft);
|
||||
}
|
||||
|
||||
async function inspectChangesWorkspaceControls(
|
||||
page: Page,
|
||||
workspace: DirtyWorkspace,
|
||||
): Promise<void> {
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1);
|
||||
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(
|
||||
visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible();
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName(
|
||||
"src/use-mounted-tab-set.ts, +37, -1",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName(
|
||||
"src/zz-deleted.ts, +0, -1",
|
||||
);
|
||||
}
|
||||
|
||||
async function reviewChangedLine(page: Page): Promise<void> {
|
||||
await test.step("the whole reviewable row reveals the gutter affordance and uses a text cursor", async () => {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, fontSize] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
||||
]);
|
||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
||||
const lineHeight = Math.round(fontSize * 1.5);
|
||||
await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5);
|
||||
|
||||
const affordance = page.getByRole("button", { name: "Add review comment" });
|
||||
await expect(affordance).toBeVisible();
|
||||
await expect(affordance.locator("svg")).toBeVisible();
|
||||
const affordanceBounds = await affordance.boundingBox();
|
||||
expect(affordanceBounds?.width).toBeCloseTo(22, 0);
|
||||
expect(affordanceBounds?.height).toBeCloseTo(22, 0);
|
||||
await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
await test.step("creates, cancels, edits, and deletes a review while keeping Changes focused", async () => {
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("scrolling clears the hovered review affordance", async () => {
|
||||
await hoverFirstChangedGutter(page);
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
|
||||
await page.getByTestId("git-diff-scroll").evaluate((element) => {
|
||||
element.scrollTop += 80;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectChangedFileHeaders(page: Page): Promise<void> {
|
||||
await test.step("file statuses follow their diff stats", async () => {
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
const addedRow = tree.getByTestId("diff-tree-file-2");
|
||||
const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" });
|
||||
await expect(modifiedStatus).toBeVisible();
|
||||
await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible();
|
||||
|
||||
const [statBounds, statusBounds] = await Promise.all([
|
||||
modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(),
|
||||
modifiedStatus.boundingBox(),
|
||||
]);
|
||||
if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds");
|
||||
expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
await test.step("every interactive file header has the same hover feedback", async () => {
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
await test.step("canvas file headers select without toggling for context menu and long press", async () => {
|
||||
const firstFile = page.getByTestId("diff-file-0-toggle");
|
||||
const deletedFile = page.getByTestId("diff-file-1-toggle");
|
||||
await deletedFile.click({ button: "right" });
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await longPressFileHeader(page, firstFile);
|
||||
await expect(firstFile).toHaveAttribute("aria-selected", "true");
|
||||
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
|
||||
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
await test.step("changes file actions open below the right-click without a reserved kebab", async () => {
|
||||
const deletedFileName = page.getByTestId("diff-file-1");
|
||||
await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/);
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
const fileRowBounds = await fileRow.boundingBox();
|
||||
expect(fileRowBounds).not.toBeNull();
|
||||
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1);
|
||||
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
async function duplicateChangedFiles(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts")))
|
||||
.toBe(AFTER);
|
||||
}
|
||||
|
||||
async function scrollDiffToTop(page: Page): Promise<void> {
|
||||
const scroll = page.getByTestId("git-diff-scroll");
|
||||
await scroll.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
});
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBe(0);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
expectReconnectingToastVisible,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { createControlledStream } from "../support/helpers/controlled-stream";
|
||||
import {
|
||||
composerLocator,
|
||||
fillComposerDraft,
|
||||
@@ -24,11 +26,13 @@ const MAC_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
|
||||
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
|
||||
const STREAMED_RESPONSE = [
|
||||
const STREAMED_TAIL = "STREAMED-REPLY-FINAL-TAIL";
|
||||
const HYDRATED_PARTIAL = "Hydrated partial continuation";
|
||||
const STREAMED_PREFIX = [
|
||||
"First paragraph with alpha-needle and pair-needle inside.",
|
||||
"Second paragraph with beta-needle and pair-needle inside.",
|
||||
`Third paragraph ${FILLER}`,
|
||||
].join("\n\n");
|
||||
].map((paragraph) => `${paragraph}\n\n`);
|
||||
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
@@ -282,50 +286,90 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
|
||||
}
|
||||
});
|
||||
|
||||
interface StreamedReplyChat extends MockAgentWorkspace {
|
||||
/** Resolves once the streamed reply has finished arriving. */
|
||||
interface StreamedReplyChat {
|
||||
cleanup(): Promise<void>;
|
||||
advancePastVisiblePrefix(): Promise<void>;
|
||||
expectHydratedPartial(): Promise<void>;
|
||||
replyFinished(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the chat first, then streams a multi-block reply into it and waits for a
|
||||
* later block, so the block holding the early needles is no longer the growing row.
|
||||
*/
|
||||
/** The provider owns the pause, so both search and hydration see incomplete output. */
|
||||
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-live-blocks-",
|
||||
title: "Chat Find live blocks",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_RESPONSE,
|
||||
mockStreamingAssistantIntervalMs: 60,
|
||||
const stream = await createControlledStream();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "chat-find-live-blocks-" }).catch(
|
||||
async (error) => {
|
||||
await stream.cleanup();
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await stream.cleanup();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
};
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: stream.provider,
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Chat Find live blocks",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await workspace.client.sendAgentMessage(agent.id, "Stream a multi block answer");
|
||||
await stream.append(STREAMED_PREFIX);
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible();
|
||||
return {
|
||||
...agent,
|
||||
cleanup,
|
||||
advancePastVisiblePrefix: async () => {
|
||||
const count = gate.getAgentStreamItemCount("assistant_message");
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await stream.append([HYDRATED_PARTIAL]);
|
||||
await gate.waitForAgentStreamItem("assistant_message", count + 1);
|
||||
await expect(page.getByTestId("assistant-message").last()).not.toContainText(
|
||||
HYDRATED_PARTIAL,
|
||||
);
|
||||
},
|
||||
expectHydratedPartial: async () => {
|
||||
// A newer daemon row forces Find to fetch the real timeline instead of
|
||||
// matching only the browser's streamed prefix.
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText(HYDRATED_PARTIAL);
|
||||
expect(gate.getTimelineRequestCount("before", agent.id)).toBeGreaterThan(0);
|
||||
},
|
||||
replyFinished: async () => {
|
||||
await agent.client.waitForFinish(agent.agentId, 60_000);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
await stream.finish([`\n\n${STREAMED_TAIL}`]);
|
||||
await workspace.client.waitForFinish(agent.id, 15_000);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectIncompleteStreamedReply(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText("beta-needle");
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText("filler399");
|
||||
await expect(page.getByTestId("assistant-message").last()).not.toContainText(STREAMED_TAIL);
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await expectIncompleteStreamedReply(page);
|
||||
await chat.advancePastVisiblePrefix();
|
||||
await openChatFind(page);
|
||||
// The first-paragraph hit must remain searchable while the same message grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.expectHydratedPartial();
|
||||
await expectIncompleteStreamedReply(page);
|
||||
await chat.replyFinished();
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText(STREAMED_TAIL);
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two paragraphs, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { trackPromptJumpRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import {
|
||||
clickChatOutlineRowEdge,
|
||||
@@ -50,6 +50,43 @@ import {
|
||||
const WIDE_VIEWPORT = { width: 1440, height: 900 };
|
||||
const LOADED_TURNS = 16;
|
||||
|
||||
async function navigateLoadedOutline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await test.step("preview across the row", async () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
await hoverChatOutlinePrompt(page, 2);
|
||||
await expectChatOutlinePreview(page, agent.prompts[1]);
|
||||
|
||||
await pointAtChatOutlineRowEdge(page, 7);
|
||||
await expectChatOutlinePreview(page, agent.prompts[6]);
|
||||
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
await test.step("jump, dismiss preview, and retain bare selection", async () => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
await test.step("track the active prompt while jumping and scrolling", async () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
await test.step("clamp the newest prompt at maximum scroll", async () => {
|
||||
await clickChatOutlineRowEdge(page, LOADED_TURNS);
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
|
||||
});
|
||||
}
|
||||
|
||||
async function jumpToNewerLoadedPrompt(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await clickChatOutlineRowEdge(page, 60);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
|
||||
}
|
||||
|
||||
test.describe("desktop chat outline", () => {
|
||||
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
|
||||
page,
|
||||
@@ -183,28 +220,14 @@ test.describe("desktop chat outline", () => {
|
||||
await expectChatOutlinePrompts(page, LOADED_TURNS);
|
||||
});
|
||||
|
||||
test("previews the prompt under the pointer, anywhere across its row", async ({ page }) => {
|
||||
await hoverChatOutlinePrompt(page, 2);
|
||||
await expectChatOutlinePreview(page, agent.prompts[1]);
|
||||
|
||||
await pointAtChatOutlineRowEdge(page, 7);
|
||||
await expectChatOutlinePreview(page, agent.prompts[6]);
|
||||
|
||||
await movePointerOffChatOutline(page);
|
||||
await expectNoChatOutlinePreview(page);
|
||||
test("previews, jumps, and tracks the reader through loaded prompts", async ({ page }) => {
|
||||
await navigateLoadedOutline(page, agent);
|
||||
});
|
||||
|
||||
test("does not preview while crossing the rail toward the sidebar", async ({ page }) => {
|
||||
await expectNoChatOutlinePreviewWhileCrossingToSidebar(page);
|
||||
});
|
||||
|
||||
test("closes the preview when the pointer leaves after a jump", async ({ page }) => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await movePointerOffChatOutline(page);
|
||||
|
||||
await expectNoChatOutlinePreview(page);
|
||||
});
|
||||
|
||||
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
||||
await focusChatOutlinePrompt(page, 1);
|
||||
await expectChatOutlinePreview(page, agent.prompts[0]);
|
||||
@@ -215,32 +238,6 @@ test.describe("desktop chat outline", () => {
|
||||
await expectActiveChatOutlinePrompt(page, 1);
|
||||
});
|
||||
|
||||
test("moves the active mark as the reader jumps and scrolls", async ({ page }) => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
});
|
||||
|
||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
});
|
||||
|
||||
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await clickChatOutlineRowEdge(page, LOADED_TURNS);
|
||||
|
||||
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.newestPrompt);
|
||||
});
|
||||
|
||||
test("keeps the current prompt active throughout a long live assistant turn", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -282,21 +279,11 @@ test.describe("desktop chat outline", () => {
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
||||
|
||||
expect(history.requestCount()).toBe(requestsBeforeJump);
|
||||
});
|
||||
|
||||
test("pins a newer loaded prompt when jumping downward from old history", async ({ page }) => {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectChatOutlinePrompts(page, 80);
|
||||
await scrollThroughOlderHistoryPages(page, 2, history);
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 30);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[29]);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 60);
|
||||
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[59]);
|
||||
await test.step(
|
||||
"jump downward from old history to a newer loaded prompt",
|
||||
jumpToNewerLoadedPrompt.bind(null, page, agent),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
chooseCommandCenterAgentControl,
|
||||
openCommandCenterForAgent,
|
||||
expectCommandCenterAgentControlSelected,
|
||||
expectFocusedAgentControls,
|
||||
expectWorkspaceAgentConfiguration,
|
||||
@@ -42,37 +41,93 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
|
||||
test.describe("Command Center agent controls", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("changes a running agent setting and preserves its selected row", async ({ page }) => {
|
||||
test("search, select, and re-rank live agent controls before navigating to an action", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-live-controls-",
|
||||
title: "Command Center live controls",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
// A stale runtime mode makes selecting the provider's supported mode
|
||||
// exercise the live setting RPC instead of the selected-row no-op.
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
await openAgentRoute(page, workspace);
|
||||
await test.step("changes a running agent setting and preserves its selected row", async () => {
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await expectCommandCenterAgentControlSelected({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await closeCommandCenter(page);
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
await test.step("matches query tokens in any order", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
});
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
await test.step("activates the best-matching row, not the highest-ranked group", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await expectCommandCenterAgentControlSelected({
|
||||
page,
|
||||
query: "load test",
|
||||
choice: "Mode › Load test",
|
||||
await test.step("drops an arrow-key selection once the query changes", async () => {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
});
|
||||
await test.step("keeps an action first when the action is the best match", async () => {
|
||||
const panel = await openCommandCenter(page);
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
@@ -100,107 +155,6 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
// model. Asserting both model and mode catches either row winning.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-relevance-",
|
||||
title: "Command Center relevance",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("matches query tokens in any order", async ({ page }) => {
|
||||
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
|
||||
// a substring of it. Only per-token matching finds this row.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-tokenized-",
|
||||
title: "Command Center tokenized search",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an action first when the action is the best match", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-action-priority-",
|
||||
title: "Command Center action priority",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("drops an arrow-key selection once the query changes", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-active-reset-",
|
||||
title: "Command Center active reset",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||
await seedMockDraftPreferences(page);
|
||||
|
||||
@@ -82,31 +82,20 @@ test("workspace file search stays geometrically stable through delayed loading a
|
||||
|
||||
await row.click();
|
||||
await expectFileTabOpen(page, FILE_PATH);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
await test.step("dropping the files scope leaves the search row the same height", async () => {
|
||||
await page.keyboard.press("Meta+P");
|
||||
|
||||
test("dropping the files scope leaves the search row the same height", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-file-search-scope-",
|
||||
title: "Scope chip height",
|
||||
});
|
||||
const reopenedPanel = page.getByTestId("command-center-panel");
|
||||
await expect(reopenedPanel).toBeVisible({ timeout: 30_000 });
|
||||
const header = reopenedPanel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
const header = panel.getByTestId("command-center-header");
|
||||
const scoped = await header.boundingBox();
|
||||
|
||||
await page.getByTestId("command-center-files-scope").click();
|
||||
await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0);
|
||||
const unscoped = await header.boundingBox();
|
||||
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
expect(scoped?.height).toBe(unscoped?.height);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center sidebar grouping", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
} finally {
|
||||
await seeded.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -107,73 +107,122 @@ async function rejectNextLabelAssignment(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const GROUP_BY_STATUS = "Group by status";
|
||||
const GROUP_BY_PROJECT = "Group by project";
|
||||
|
||||
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
|
||||
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("group");
|
||||
|
||||
const entry = panel.getByText(label, { exact: true });
|
||||
await expect(entry).toBeVisible({ timeout: 30_000 });
|
||||
// The entry always names the mode you are not in, so its opposite must never be listed.
|
||||
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
|
||||
|
||||
await entry.click();
|
||||
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Command center workspace management", () => {
|
||||
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
|
||||
|
||||
test("manage the active workspace and scope its commands when leaving the route", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-manage-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await collapseProjectSection(page, workspace);
|
||||
await test.step("renames the active workspace while its sidebar row is unmounted", async () => {
|
||||
await collapseProjectSection(page, workspace);
|
||||
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
await runCommand(page, "rename", "Rename workspace");
|
||||
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
|
||||
// focus race with its focus-restore, still renders — you just cannot type into it.
|
||||
const input = page.getByTestId("workspace-rename-modal-global-input");
|
||||
await expect(input).toBeFocused({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue(workspace.workspaceName);
|
||||
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
const customTitle = "Renamed From Palette";
|
||||
await input.fill(customTitle);
|
||||
await page.getByTestId("workspace-rename-modal-global-submit").click();
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
// Re-expand the section to read the row back.
|
||||
const header = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: workspace.projectDisplayName });
|
||||
await header.click();
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
await test.step("copies the workspace path and confirms with a toast", async () => {
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
test("copies the workspace path and confirms with a toast", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await runCommand(page, "copy path", "Copy workspace path");
|
||||
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
|
||||
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
await test.step("flips the pin entry to Unpin after pinning from the palette", async () => {
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await action(panel, "Unpin").click();
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toHaveCount(0);
|
||||
});
|
||||
await test.step("grouping commands persist across reload and switch back", async () => {
|
||||
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const statusList = page.getByTestId("sidebar-status-list-scroll");
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
// Query-only: the grouping entry stays out of the default empty-query list.
|
||||
const panel = await openCommandCenter(page);
|
||||
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await runCommand(page, "pin", "Pin to top");
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
|
||||
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectList).toHaveCount(0);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("pin");
|
||||
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Pin to top")).toHaveCount(0);
|
||||
// The only assertion that catches a flip which never reached persisted storage.
|
||||
await page.reload();
|
||||
await expect(statusList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Grouping persists, so the run must leave the sidebar back in project mode.
|
||||
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
|
||||
await expect(projectList).toBeVisible({ timeout: 30_000 });
|
||||
await expect(statusList).toHaveCount(0);
|
||||
});
|
||||
await test.step("lists only the globally-handled toggle off a workspace route", async () => {
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -183,35 +232,6 @@ test.describe("Command center workspace management", () => {
|
||||
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
|
||||
// off a workspace route would give the user two entries that close the palette and do nothing.
|
||||
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
|
||||
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
// All three are listed while a workspace route owns the handlers.
|
||||
const workspacePanel = await openCommandCenter(page);
|
||||
await workspacePanel.getByTestId("command-center-input").fill("toggle");
|
||||
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
|
||||
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("toggle");
|
||||
|
||||
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
|
||||
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggles a label on the active workspace from the palette", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
|
||||
|
||||
@@ -44,6 +44,16 @@ test.describe("Command center workspaces", () => {
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await test.step("single-host results show project and branch without a host label", async () => {
|
||||
const singleHostPanel = await openCommandCenter(page);
|
||||
const singleHostRow = singleHostPanel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(singleHostRow.getByTestId("command-center-workspace-subtitle")).toHaveText(
|
||||
`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
@@ -121,38 +131,4 @@ test.describe("Command center workspaces", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("single-host workspace subtitle omits the host and shows project · branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-workspace-single-",
|
||||
title: WORKSPACE_TITLE,
|
||||
});
|
||||
|
||||
try {
|
||||
execFileSync("git", ["checkout", "-b", WORKSPACE_BRANCH], {
|
||||
cwd: seeded.repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
|
||||
// No secondary host: with a single host, the host label is gated away.
|
||||
await gotoAppShell(page);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const subtitle = row.getByTestId("command-center-workspace-subtitle");
|
||||
await expect(subtitle).toHaveText(`${seeded.projectDisplayName} · ${WORKSPACE_BRANCH}`);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,36 +420,38 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
test("context details preserve composer focus on hover and leave Find available after keyboard dismissal", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
|
||||
test("Find remains available after dismissing context details", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
await test.step("pointer entry and dismissal preserve composer focus", async () => {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const input = composerLocator(page);
|
||||
await input.focus();
|
||||
await hoverContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
await leaveContextWindowMeter(page);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
|
||||
await test.step("keyboard dismissal restores trigger focus and Find remains available", async () => {
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: (await meter.getAttribute("aria-label"))!,
|
||||
exact: true,
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
await trigger.focus();
|
||||
await expect(contextWindowDetails(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(page.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
@@ -105,25 +105,41 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
test("opens screenshot and non-ASCII Markdown 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)",
|
||||
"[Capture](.dev/tablet-review/capture.png) [报告](docs/reports/开户赠金.md) [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 mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await 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("Open the non-ASCII report named by its Markdown link", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
});
|
||||
await test.step("Explain why the missing screenshot cannot open", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
@@ -136,31 +152,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
title: "Non-ASCII file link",
|
||||
initialPrompt: "Show the report link",
|
||||
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const order of ["raw-first", "href-first"] as const) {
|
||||
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -690,7 +681,37 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await test.step("render sibling and parent-relative images in Markdown preview", async () => {
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const guidePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(guidePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await guidePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
@@ -724,84 +745,30 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
test("isolates an HTML preview, blocks fetch egress, and refreshes while retaining source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-preview-" });
|
||||
const htmlPath = path.join(workspace.repoPath, "plan.html");
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Visual plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "plan.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.host).toBeVisible();
|
||||
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
||||
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
||||
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
htmlPath,
|
||||
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "probe.html"),
|
||||
`<!doctype html><html><head><title>probe</title></head><body>
|
||||
<h1>Visual plan</h1>
|
||||
<h1 id="script-result">Inline script did not run</h1>
|
||||
<p id="network-result">Network request not attempted</p>
|
||||
<p id="document-mode">Standards mode not detected</p>
|
||||
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
||||
<script>
|
||||
function report(id, label, probe) {
|
||||
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
||||
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
||||
}
|
||||
report("parent", "Parent DOM", function () { return parent.document.body; });
|
||||
report("storage", "Storage", function () { return localStorage.length; });
|
||||
report("cookie", "Cookies", function () { return document.cookie; });
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.getElementById("script-result").textContent = "Inline script ran";
|
||||
if (document.compatMode === "CSS1Compat") {
|
||||
@@ -822,6 +789,8 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "probe.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.host).toBeVisible();
|
||||
await expect(preview.document.getByRole("heading", { name: "Visual plan" })).toBeVisible();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Inline script ran" }),
|
||||
).toBeVisible();
|
||||
@@ -832,34 +801,26 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
preview.document.getByText("Network request blocked", { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(blockedRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("isolates HTML plans from the app origin and storage", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-origin-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "origin.html"),
|
||||
`<!doctype html><html><body>
|
||||
<p id="parent">?</p><p id="storage">?</p><p id="cookie">?</p>
|
||||
<script>
|
||||
function report(id, label, probe) {
|
||||
try { probe(); document.getElementById(id).textContent = label + " reachable"; }
|
||||
catch (error) { document.getElementById(id).textContent = label + " blocked"; }
|
||||
}
|
||||
report("parent", "Parent DOM", function () { return parent.document.body; });
|
||||
report("storage", "Storage", function () { return localStorage.length; });
|
||||
report("cookie", "Cookies", function () { return document.cookie; });
|
||||
</script>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "origin.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
||||
await test.step("isolate the preview from app origin and storage", async () => {
|
||||
await expect(preview.host).toHaveAttribute("sandbox", /allow-scripts/);
|
||||
await expect(preview.host).not.toHaveAttribute("sandbox", /allow-same-origin/);
|
||||
await expect(preview.document.getByText("Parent DOM blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Storage blocked", { exact: true })).toBeVisible();
|
||||
await expect(preview.document.getByText("Cookies blocked", { exact: true })).toBeVisible();
|
||||
});
|
||||
await test.step("refresh a changed HTML plan and switch to source and back", async () => {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "probe.html"),
|
||||
"<!doctype html><html><body><h1>Updated plan</h1></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,20 +6,14 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openNewWorkspaceComposer,
|
||||
selectNewWorkspaceHost,
|
||||
selectNewWorkspaceProject,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const DRAFT = `Please investigate the workspace startup failure.
|
||||
@@ -29,70 +23,6 @@ Trace the request from the app through the daemon, preserve the existing behavio
|
||||
test.describe("New workspace composer draft", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps the draft when the project changes", async ({ page }) => {
|
||||
const firstProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-a-",
|
||||
});
|
||||
const secondProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-project-b-",
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: firstProject.projectKey,
|
||||
projectDisplayName: firstProject.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: secondProject.projectKey,
|
||||
projectDisplayName: secondProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await secondProject.cleanup();
|
||||
await firstProject.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the draft when the host changes", async ({ page }) => {
|
||||
const project: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-draft-host-",
|
||||
});
|
||||
const secondaryServerId = "new-workspace-draft-secondary-host";
|
||||
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "Primary host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: secondaryServerId,
|
||||
label: "Secondary host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await fillNewWorkspaceDraft(page, DRAFT);
|
||||
await selectNewWorkspaceHost(page, "Secondary host");
|
||||
|
||||
await expectNewWorkspaceDraft(page, DRAFT);
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceControlsEnabled,
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
selectNewWorkspaceHost,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectNewWorkspaceTriggerLabelsAligned,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
@@ -90,6 +93,11 @@ test.describe("New workspace entry points", () => {
|
||||
projectLabel: seeded.projectDisplayName,
|
||||
hostLabel: "localhost",
|
||||
});
|
||||
await test.step("changing hosts preserves the draft", async () => {
|
||||
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a host");
|
||||
await selectNewWorkspaceHost(page, "Secondary host");
|
||||
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a host");
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -121,6 +129,9 @@ test.describe("New workspace entry points", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("host-picker-trigger")).toHaveCount(0);
|
||||
await test.step("Ctrl+P opens the project picker with search focused", async () => {
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -137,20 +148,6 @@ test.describe("New workspace entry points", () => {
|
||||
await expectNewWorkspaceControlsEnabled(page);
|
||||
});
|
||||
|
||||
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
await openNewWorkspaceProjectPickerWithShortcut(page);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -220,6 +217,8 @@ test.describe("New workspace entry points", () => {
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
|
||||
await fillNewWorkspaceDraft(page, "Keep this draft while choosing a project");
|
||||
|
||||
// Manually override the selection to C from inside A's screen. This stale
|
||||
// manualProjectKey is what the reused 'new' screen must reset when the next
|
||||
// route-driven navigation targets a different project.
|
||||
@@ -230,6 +229,9 @@ test.describe("New workspace entry points", () => {
|
||||
await expect(optionC).toBeVisible({ timeout: 30_000 });
|
||||
await optionC.click();
|
||||
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
|
||||
await test.step("changing projects preserves the draft", async () => {
|
||||
await expectNewWorkspaceDraft(page, "Keep this draft while choosing a project");
|
||||
});
|
||||
|
||||
// Navigate via B's row icon. B must be preselected — the route project wins
|
||||
// because the stale manual choice (C) was reset on the route change. If the
|
||||
|
||||
@@ -88,11 +88,23 @@ async function openNewWorkspaceFromContextProject(
|
||||
await expectNewWorkspaceProjectSelected(page, "Context project");
|
||||
}
|
||||
|
||||
const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
crossHostProject: async ({ page: _page }, provide) => {
|
||||
const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon(
|
||||
"new-workspace-project-preservation-secondary",
|
||||
);
|
||||
const test = base.extend<
|
||||
{ crossHostProject: CrossHostProjectScenario },
|
||||
{ secondaryDaemon: IsolatedHostDaemon }
|
||||
>({
|
||||
secondaryDaemon: [
|
||||
async ({ e2eWorker }, provide) => {
|
||||
void e2eWorker;
|
||||
const daemon = await startIsolatedHostDaemon("new-workspace-project-preservation-secondary");
|
||||
try {
|
||||
await provide(daemon);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
crossHostProject: async ({ page: _page, secondaryDaemon }, provide) => {
|
||||
const contextRepo = await createTempGitRepo("new-workspace-context-");
|
||||
const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", {
|
||||
originUrl: SHARED_REMOTE_URL,
|
||||
@@ -177,7 +189,6 @@ const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({
|
||||
await primaryDuplicateRepo.cleanup().catch(() => undefined);
|
||||
await secondaryFallbackRepo.cleanup().catch(() => undefined);
|
||||
await secondarySharedRepo.cleanup().catch(() => undefined);
|
||||
await secondaryDaemon.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// Regression for "the local / worktree selection in the new workspace is not
|
||||
// remembered." The isolation choice persists in the create-form preferences
|
||||
// (FormPreferences.isolation), so it must survive the create→reopen remount:
|
||||
// creating a worktree workspace navigates away from /new and unmounts it, and
|
||||
// reopening New Workspace has to still show "New worktree".
|
||||
test.describe("New workspace isolation memory", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
// First visit: the screen opens on Local, switch it to New worktree and create.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
|
||||
// Second visit (fresh mount of /new): the worktree choice must stick.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
attachButton,
|
||||
expectChatComposerActive,
|
||||
expectTerminalComposerActive,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
terminalPromptInput,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-composer-profile",
|
||||
name: "Composer Profile",
|
||||
command: "/bin/echo",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
|
||||
test.describe("New workspace: the composer is one control in two modes", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-composer-" });
|
||||
profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("a chat draft does not leak into the terminal prompt", async () => {
|
||||
await fillNewWorkspaceDraft(page, "a chat-only draft");
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("");
|
||||
});
|
||||
|
||||
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
|
||||
name: "Launch",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
|
||||
await fillTerminalPrompt(page, "a terminal-only draft");
|
||||
await selectChatLaunch(page);
|
||||
await expectChatComposerActive(page);
|
||||
await expectNewWorkspaceDraft(page, "a chat-only draft");
|
||||
});
|
||||
|
||||
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectChatLaunchSelected,
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
patchTerminalProfiles,
|
||||
seedTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// Wait for input until project cleanup kills the terminal. A fixed lifetime
|
||||
// can expire before a busy browser attaches or finishes revisiting the composer.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
test.describe("New workspace: launch target memory", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let profileSeed: TerminalProfileSeed;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "launch-memory-" });
|
||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await profileSeed.restore();
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const openNewWorkspace = () =>
|
||||
openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||
await openNewWorkspace();
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "remember me");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "remembered: remember me");
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
});
|
||||
|
||||
await test.step("switching to Chat and submitting preselects Chat the next time", async () => {
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
// The chat handoff navigates asynchronously (draft -> created agent). Let
|
||||
// it fully settle before moving on, so it can't fire a late redirect
|
||||
// that races the next step's navigation.
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 });
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
|
||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
|
||||
await patchTerminalProfiles([]);
|
||||
|
||||
await openNewWorkspace();
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
@@ -15,6 +16,12 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
attachButton,
|
||||
expectChatComposerActive,
|
||||
expectTerminalComposerActive,
|
||||
expectChatLaunchSelected,
|
||||
patchTerminalProfiles,
|
||||
selectChatLaunch,
|
||||
expectTerminalLaunchSelected,
|
||||
expectTerminalOutputContains,
|
||||
expectTerminalPreviewCommand,
|
||||
@@ -67,21 +74,50 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({
|
||||
test("switch launch modes, run prompt and bare profiles, and remember successful targets", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await test.step("pick a profile that takes a prompt, type one, and submit", async () => {
|
||||
await test.step("open New workspace to choose a launch target", async () => {
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
});
|
||||
await test.step("a chat draft does not leak into the terminal prompt", async () => {
|
||||
await fillNewWorkspaceDraft(page, "a chat-only draft");
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("");
|
||||
});
|
||||
|
||||
await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => {
|
||||
await expect(attachButton(page)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-testid="message-input-root"]:visible').getByRole("button", {
|
||||
name: "Launch",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => {
|
||||
await fillTerminalPrompt(page, "a terminal-only draft");
|
||||
await selectChatLaunch(page);
|
||||
await expectChatComposerActive(page);
|
||||
await expectNewWorkspaceDraft(page, "a chat-only draft");
|
||||
});
|
||||
|
||||
await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalComposerActive(page);
|
||||
await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft");
|
||||
});
|
||||
await test.step("submit the prompt profile", async () => {
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "fix the flaky test");
|
||||
await submitTerminalLaunch(page);
|
||||
});
|
||||
@@ -96,6 +132,7 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await selectLaunchOption(page, BARE_PROFILE.id);
|
||||
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
|
||||
@@ -107,6 +144,31 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
||||
});
|
||||
await test.step("successful launches remember their target across visits", async () => {
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectTerminalLaunchSelected(page, BARE_PROFILE.name);
|
||||
await selectChatLaunch(page);
|
||||
await expectChatLaunchSelected(page);
|
||||
await submitNewWorkspacePrompt(page, "hello from chat");
|
||||
await page.waitForURL(hasLeftNewWorkspaceRoute, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectChatLaunchSelected(page);
|
||||
});
|
||||
await test.step("a removed remembered terminal profile falls back to Chat", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
try {
|
||||
await patchTerminalProfiles([]);
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectChatLaunchSelected(page);
|
||||
} finally {
|
||||
await patchTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
|
||||
@@ -157,6 +219,10 @@ test.describe("New workspace: launching a terminal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,9 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
await projectB?.cleanup();
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
|
||||
test("workspace button and shortcut follow the active project across visits", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
@@ -183,6 +185,15 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await test.step("global button follows project B", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
@@ -191,27 +202,15 @@ test.describe("New workspace preselects the open workspace's project", () => {
|
||||
});
|
||||
await pressNewWorkspaceShortcut(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
|
||||
test("New workspace button preselects the project you are looking at", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectB.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: projectA.workspaceId,
|
||||
});
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
await test.step("global button follows project A", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
|
||||
});
|
||||
});
|
||||
|
||||
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
selectWorkspaceIsolation,
|
||||
expectWorkspaceIsolationSelected,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
@@ -723,9 +725,26 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
await expectWorkspaceIsolationSelected(page, "local");
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(isolationPopup);
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
await test.step("cancel the branch picker without changing the selection", async () => {
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
});
|
||||
await test.step("select the filtered starting branch with the keyboard", async () => {
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
});
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
@@ -749,6 +768,10 @@ test.describe("New workspace flow", () => {
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
await test.step("the worktree choice survives creating and reopening New workspace", async () => {
|
||||
await openNewWorkspaceComposer(page, openedProject);
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
@@ -890,57 +913,6 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("picker-escape-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
|
||||
@@ -226,10 +226,19 @@ test.describe("narrow touch browser", () => {
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await test.step("reveal the first match below the floating Find widget", async () => {
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
});
|
||||
await test.step("tap the next uncovered match while controls stay inside the pane", async () => {
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await expectFindInsidePane(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
});
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
@@ -332,33 +341,6 @@ for (const startingField of ["Find in pane", "Replace with"] as const) {
|
||||
}
|
||||
|
||||
test.describe("the active match stays visible under the floating widget", () => {
|
||||
test.describe("narrow touch browser", () => {
|
||||
test.use({ hasTouch: true });
|
||||
test("reveals a short file's match that the widget would cover", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
|
||||
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "touch.txt");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await source(page).focus();
|
||||
await source(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.getByRole("button", { name: "Next match" }).tap();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await expectActiveMatchUncovered(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
|
||||
await expect(query(page)).toHaveValue("needle");
|
||||
await page.getByRole("button", { name: "Close Find" }).tap();
|
||||
await expect(source(page)).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -749,7 +749,6 @@ function observePluginReload(page: Page) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -814,12 +813,6 @@ async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string
|
||||
});
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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,
|
||||
@@ -833,6 +826,9 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
"Plugin v1",
|
||||
);
|
||||
|
||||
// Advance the real browser client's RPC timers while the daemon's build gate
|
||||
// remains held on its own clock.
|
||||
await page.clock.install();
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
@@ -851,7 +847,7 @@ test("keeps Reload pending past a minute while the plugin queue is busy", async
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
await page.clock.fastForward(66_000);
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -316,7 +316,9 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
|
||||
test("opens a GitHub-backed workspace with folder metadata and pointer and keyboard hover cards", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-remote-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
@@ -335,6 +337,46 @@ test.describe("Sidebar workspace list", () => {
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText("test-owner/test-repo");
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await expect(row).toContainText("main");
|
||||
|
||||
await test.step("inspect host metadata before opening the workspace", async () => {
|
||||
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
});
|
||||
|
||||
await test.step("open the workspace and inspect its selected folder header", async () => {
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: projectName,
|
||||
});
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
|
||||
await test.step("dismiss a clicked row's hover card when the pointer leaves", async () => {
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
|
||||
await test.step("retain keyboard preview until Escape or focus leaves the row", async () => {
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -354,107 +396,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header uses the selected folder name instead of its GitHub remote", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "sidebar-header-",
|
||||
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||
});
|
||||
|
||||
try {
|
||||
const projectName = path.basename(workspace.repoPath);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, projectName);
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: projectName,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
expect(workspace.workspaceName).toBe("main");
|
||||
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
|
||||
"main",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card shows host as metadata", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-host-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
|
||||
const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost");
|
||||
await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await row.click();
|
||||
await expect(row).toBeFocused();
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
@@ -495,11 +436,17 @@ test.describe("Sidebar workspace list", () => {
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("showMobileAgent open and close transition", async ({ page }) => {
|
||||
test("opens mobile navigation beside the close button and closes it again", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await test.step("place the close button beside the first navigation row", async () => {
|
||||
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);
|
||||
});
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
@@ -576,15 +523,14 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pinned sidebar at half of a 14-inch Mac display", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the left toggle center-owned without left window controls", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await test.step("start with pinned navigation at half of a 14-inch Mac display", async () => {
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-list-backdrop")).not.toBeVisible();
|
||||
});
|
||||
|
||||
const openToggle = page.getByTestId("menu-button");
|
||||
const openIcon = openToggle.locator("svg").first();
|
||||
await expect(openIcon).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { test } from "../support/fixtures";
|
||||
import { createControlledStream } from "../support/helpers/controlled-stream";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
@@ -27,31 +28,46 @@ interface ViewedTimelineScenario {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
type ViewedAgentConfig = Pick<
|
||||
Parameters<SeedDaemonClient["createAgent"]>[0],
|
||||
"provider" | "modeId" | "model"
|
||||
>;
|
||||
|
||||
async function seedViewedTimelineScenario(
|
||||
options: { firstAgentModel?: string } = {},
|
||||
options: { firstAgent?: ViewedAgentConfig } = {},
|
||||
): Promise<ViewedTimelineScenario> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
|
||||
const createAgent = (title: string, model = "ten-second-stream") =>
|
||||
workspace.client.createAgent({
|
||||
const createAgent = (
|
||||
title: string,
|
||||
config: ViewedAgentConfig = {
|
||||
provider: "mock",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
},
|
||||
) =>
|
||||
workspace.client.createAgent({
|
||||
...config,
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
modeId: "load-test",
|
||||
model,
|
||||
});
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat", options.firstAgentModel),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
repoPath: workspace.repoPath,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
try {
|
||||
const [firstAgent, secondAgent] = await Promise.all([
|
||||
createAgent("First viewed chat", options.firstAgent),
|
||||
createAgent("Second viewed chat"),
|
||||
]);
|
||||
return {
|
||||
client: workspace.client,
|
||||
workspaceId: workspace.workspaceId,
|
||||
repoPath: workspace.repoPath,
|
||||
firstAgentId: firstAgent.id,
|
||||
secondAgentId: secondAgent.id,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface RestoredLayoutChat {
|
||||
@@ -306,19 +322,39 @@ test.describe("Viewed agent timelines", () => {
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
||||
const stream = await createControlledStream();
|
||||
const scenario = await seedViewedTimelineScenario({
|
||||
firstAgent: { provider: stream.provider },
|
||||
}).catch(async (error) => {
|
||||
await stream.cleanup();
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
|
||||
await stream.append(["The first chat is still producing output."]);
|
||||
await expect(
|
||||
page.getByText("The first chat is still producing output.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
|
||||
const agents = await scenario.client.fetchAgents();
|
||||
expect(
|
||||
agents.entries.find((entry) => entry.agent.id === scenario.firstAgentId)?.agent.status,
|
||||
).toBe("running");
|
||||
await stream.finish([" Completed while hidden."]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expectAgentConsistentlyIdle(page, "First viewed chat");
|
||||
await expect(page.getByText("Completed while hidden.", { exact: false })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
try {
|
||||
await stream.cleanup();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<CreatedAgentCwdAssertion | null> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "@getpaseo/plugin/server/acp";
|
||||
import { z } from "zod";
|
||||
|
||||
/** A single-turn producer controlled through the daemon, before timeline persistence. */
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
let startTurn!: (append: (chunks: string[], finish: boolean) => void) => void;
|
||||
const turn = new Promise<(chunks: string[], finish: boolean) => void>((resolve) => {
|
||||
startTurn = resolve;
|
||||
});
|
||||
server.handle(
|
||||
defineRpc({
|
||||
name: "append",
|
||||
input: z.object({ chunks: z.array(z.string()), finish: z.boolean() }),
|
||||
output: z.null(),
|
||||
}),
|
||||
async ({ chunks, finish }) => {
|
||||
const append = await turn;
|
||||
append(chunks, finish);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
server.registerProvider(
|
||||
runAcpProvider({ id: "controlled-stream", label: "Controlled stream", connector: connect }),
|
||||
);
|
||||
return () => {};
|
||||
|
||||
function connect() {
|
||||
let controller: ReadableStreamDefaultController<AcpStreamMessage>;
|
||||
return {
|
||||
readable: new ReadableStream<AcpStreamMessage>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
},
|
||||
}),
|
||||
writable: new WritableStream<AcpStreamMessage>({
|
||||
write(message) {
|
||||
if (!("method" in message) || !("id" in message)) return;
|
||||
if (message.method === "session/prompt") {
|
||||
let finished = false;
|
||||
startTurn((chunks, finish) => {
|
||||
if (finished) return;
|
||||
for (const text of chunks) {
|
||||
controller.enqueue({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId: "controlled",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (finish) {
|
||||
finished = true;
|
||||
controller.enqueue({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: { stopReason: "end_turn" },
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let result: unknown = {};
|
||||
if (message.method === "initialize") {
|
||||
result = { protocolVersion: 1, agentCapabilities: {} };
|
||||
} else if (message.method === "session/new") {
|
||||
result = { sessionId: "controlled" };
|
||||
}
|
||||
controller.enqueue({ jsonrpc: "2.0", id: message.id, result });
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "controlled-stream-test", "requirements": { "paseo": ">=0.8.0" } }
|
||||
@@ -0,0 +1,57 @@
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
|
||||
type ControlledStreamClient = Pick<
|
||||
DaemonClient,
|
||||
| "connect"
|
||||
| "close"
|
||||
| "getDaemonConfig"
|
||||
| "patchDaemonConfig"
|
||||
| "installDirectoryPlugin"
|
||||
| "removePlugin"
|
||||
| "invokePluginRpc"
|
||||
>;
|
||||
|
||||
/** Owns one real provider turn whose completion the test explicitly releases. */
|
||||
export async function createControlledStream() {
|
||||
const client = await connectDaemonClient<ControlledStreamClient>({
|
||||
clientIdPrefix: "controlled-stream",
|
||||
});
|
||||
const previous = await client.getDaemonConfig();
|
||||
let appended = false;
|
||||
const emit = (chunks: string[], finish: boolean) =>
|
||||
client.invokePluginRpc("controlled-stream-test", "append", { chunks, finish });
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
if (appended) await emit([], true);
|
||||
await client.removePlugin("controlled-stream-test");
|
||||
} finally {
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(
|
||||
path.resolve(__dirname, "../fixtures/controlled-stream-plugin"),
|
||||
);
|
||||
return {
|
||||
provider: "controlled-stream",
|
||||
append: async (chunks: string[]) => {
|
||||
await emit(chunks, false);
|
||||
appended = true;
|
||||
},
|
||||
finish: async (chunks: string[] = []) => {
|
||||
await emit(chunks, true);
|
||||
},
|
||||
cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { findScrollJumps, type ScrollFrame } from "./scroll-jumps";
|
||||
|
||||
test("scroll detector distinguishes entered image growth from a simultaneous viewport jump", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 1000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -150, height: 150 },
|
||||
{ id: "reading", top: 0, height: 100 },
|
||||
],
|
||||
anchor: "reading",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1400,
|
||||
scrollTop: 900,
|
||||
wheelTotal: 100,
|
||||
lastWheelAt: 1050,
|
||||
rows: [
|
||||
{ id: "image", top: -50, height: 1150 },
|
||||
{ id: "reading", top: 1100, height: 100 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, scrollTop: 200 },
|
||||
{
|
||||
...after,
|
||||
wheelTotal: 1000,
|
||||
scrollTop: 0,
|
||||
rows: after.rows.map((row) => ({ ...row, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{ ...after, rows: after.rows.map((row) => ({ ...row, top: row.top - 500 })) },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
findScrollJumps([
|
||||
{ ...before, anchor: "image", rows: [{ id: "image", top: 0, height: 150 }] },
|
||||
{ ...after, rows: [{ id: "image", top: 600, height: 1150 }] },
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector accounts for delayed wheel input and growth below the new reader", () => {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2000,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 800,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -520, height: 278 },
|
||||
{ id: "image", top: -242, height: 184 },
|
||||
{ id: "old-reader", top: -58, height: 130 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 0,
|
||||
unmounted: 0,
|
||||
};
|
||||
const wheel = { ...before, at: 1100, wheelTotal: 480, lastWheelAt: 1099 };
|
||||
const after: ScrollFrame = {
|
||||
...wheel,
|
||||
at: 1220,
|
||||
scrollTop: 1520,
|
||||
scrollHeight: 5041,
|
||||
anchor: "new-reader",
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 225 },
|
||||
{ id: "old-reader", top: 463, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, wheel, after])).toEqual([]);
|
||||
// The previous 480 px input has already moved the viewport; the recent
|
||||
// budget includes it, but only the next 480 px belongs to this frame.
|
||||
const steadyBefore = { ...before, at: 1050, wheelTotal: 480, lastWheelAt: 1049 };
|
||||
const steadyAfter = {
|
||||
...after,
|
||||
at: 1100,
|
||||
wheelTotal: 960,
|
||||
lastWheelAt: 1099,
|
||||
scrollHeight: 6000,
|
||||
rows: [
|
||||
{ id: "new-reader", top: -40, height: 278 },
|
||||
{ id: "image", top: 238, height: 1184 },
|
||||
{ id: "old-reader", top: 1422, height: 130 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([steadyBefore, steadyAfter])).toEqual([]);
|
||||
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
wheel,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("scroll detector follows the entered image through resize and virtual offset correction", () => {
|
||||
for (const height of [225, 1632]) {
|
||||
const before: ScrollFrame = {
|
||||
at: 1000,
|
||||
scrollTop: 2208,
|
||||
scrollHeight: 5000,
|
||||
viewportHeight: 516,
|
||||
virtualized: true,
|
||||
loading: false,
|
||||
rows: [
|
||||
{ id: "image", top: -704, height: 560 },
|
||||
{ id: "caption", top: -144, height: 100 },
|
||||
{ id: "old-reader", top: -44, height: 127 },
|
||||
],
|
||||
anchor: "old-reader",
|
||||
wheelTotal: 0,
|
||||
lastWheelAt: 0,
|
||||
inputFinishedAt: null,
|
||||
imageLoads: 0,
|
||||
mounted: 3,
|
||||
unmounted: 0,
|
||||
};
|
||||
const resize = height - 560;
|
||||
const after: ScrollFrame = {
|
||||
...before,
|
||||
at: 1060,
|
||||
// The virtualizer corrects 110px of estimates while the wheel moves 160px.
|
||||
scrollTop: 1938,
|
||||
scrollHeight: 5000 + resize - 110,
|
||||
wheelTotal: 160,
|
||||
lastWheelAt: 1020,
|
||||
imageLoads: 1,
|
||||
rows: [
|
||||
{ id: "image", top: -544, height },
|
||||
{ id: "caption", top: 16 + resize, height: 100 },
|
||||
{ id: "old-reader", top: 116 + resize, height: 127 },
|
||||
],
|
||||
};
|
||||
expect(findScrollJumps([before, after])).toEqual([]);
|
||||
// Resizing must not excuse additional viewport movement.
|
||||
expect(
|
||||
findScrollJumps([
|
||||
before,
|
||||
{
|
||||
...after,
|
||||
rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })),
|
||||
},
|
||||
]),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// scrollTop also includes virtualizer estimate corrections. Locate the row
|
||||
// entered by wheel input from its viewport movement instead. Its old height
|
||||
// identifies the reader before a simultaneous image resize takes effect.
|
||||
const intendedRow = previous.rows.find((row) => {
|
||||
const next = currentRows.get(row.id);
|
||||
if (!next || idle || wheelBudget <= 0) return false;
|
||||
const travel = next.top - row.top;
|
||||
return (
|
||||
travel >= -8 &&
|
||||
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
|
||||
(next.top <= 8 || current.scrollTop === 0) &&
|
||||
next.top + row.height > 8
|
||||
);
|
||||
});
|
||||
const enteredRowResize = intendedRow
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + height - row.height;
|
||||
}, 0)
|
||||
: 0;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findScrollJumps, type ScrollFrame } from "./scroll-jumps";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
@@ -12,27 +13,6 @@ export const scrollCadences = [
|
||||
] as const;
|
||||
|
||||
type Cadence = (typeof scrollCadences)[number];
|
||||
interface RowFrame {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
export interface ScrollFrame {
|
||||
at: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
viewportHeight: number;
|
||||
virtualized: boolean;
|
||||
loading: boolean;
|
||||
rows: RowFrame[];
|
||||
anchor: string | null;
|
||||
wheelTotal: number;
|
||||
lastWheelAt: number;
|
||||
inputFinishedAt: number | null;
|
||||
imageLoads: number;
|
||||
mounted: number;
|
||||
unmounted: number;
|
||||
}
|
||||
interface Recording {
|
||||
frames: ScrollFrame[];
|
||||
finishInput(): void;
|
||||
@@ -404,80 +384,6 @@ export async function recordUpwardTraversal(
|
||||
});
|
||||
}
|
||||
|
||||
export function findScrollJumps(frames: ScrollFrame[]) {
|
||||
return frames.flatMap((current, index) => {
|
||||
const previous = frames[index - 1];
|
||||
if (!previous?.anchor) return [];
|
||||
// Wheel input can move the reading line onto an image before it expands.
|
||||
// Follow that intended row, not text now below the image.
|
||||
const recent = frames.findLast((frame) => frame.at <= previous.at - 100);
|
||||
const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0);
|
||||
const currentRows = new Map(current.rows.map((row) => [row.id, row]));
|
||||
const anchor = previous.anchor;
|
||||
const before = currentRows.has(anchor)
|
||||
? previous.rows.find((row) => row.id === anchor)!
|
||||
: previous.rows
|
||||
.filter((row) => currentRows.has(row.id))
|
||||
.sort((left, right) => Math.abs(left.top - 8) - Math.abs(right.top - 8))[0];
|
||||
if (!before) {
|
||||
throw new Error(`No shared reading geometry between frames ${index - 1} and ${index}`);
|
||||
}
|
||||
const after = currentRows.get(before.id)!;
|
||||
const movement = after.top - before.top;
|
||||
// A busy main thread can deliver wheel movement hundreds of milliseconds
|
||||
// after its event. Only assert idle stability after the driver stops input.
|
||||
const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250;
|
||||
// scrollTop also includes virtualizer estimate corrections. Locate the row
|
||||
// entered by wheel input from its viewport movement instead. Its old height
|
||||
// identifies the reader before a simultaneous image resize takes effect.
|
||||
const intendedRow = previous.rows.find((row) => {
|
||||
const next = currentRows.get(row.id);
|
||||
if (!next || idle || wheelBudget <= 0) return false;
|
||||
const travel = next.top - row.top;
|
||||
return (
|
||||
travel >= -8 &&
|
||||
travel <= Math.min(wheelBudget, previous.scrollTop) + 32 &&
|
||||
(next.top <= 8 || current.scrollTop === 0) &&
|
||||
next.top + row.height > 8
|
||||
);
|
||||
});
|
||||
const enteredRowResize = intendedRow
|
||||
? previous.rows.reduce((growth, row) => {
|
||||
if (row.top < intendedRow.top || row.top >= before.top) return growth;
|
||||
const height = currentRows.get(row.id)?.height ?? row.height;
|
||||
return growth + height - row.height;
|
||||
}, 0)
|
||||
: 0;
|
||||
const readingMovement = movement - enteredRowResize;
|
||||
const excessForward = readingMovement > wheelBudget + 32;
|
||||
// Upward wheel input moves the same text DOWN the viewport. A negative move
|
||||
// is a reversal, independent of legitimate scrollTop compensation on prepend.
|
||||
if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
frame: index,
|
||||
at: current.at,
|
||||
row: before.id,
|
||||
movement,
|
||||
idle,
|
||||
excessForward,
|
||||
resizedRows: current.rows.flatMap((row) => {
|
||||
const old = previous.rows.find((candidate) => candidate.id === row.id);
|
||||
return old && old.height !== row.height
|
||||
? [{ id: row.id, before: old.height, after: row.height }]
|
||||
: [];
|
||||
}),
|
||||
loading: current.loading || previous.loading,
|
||||
virtualized: current.virtualized,
|
||||
heightChange: current.scrollHeight - previous.scrollHeight,
|
||||
imageLoads: current.imageLoads - previous.imageLoads,
|
||||
frameGapMs: current.at - previous.at,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export async function reportScrollJumps(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof useUnistyles>["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(
|
||||
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
|
||||
[labels.closeSidebar, closeSidebar],
|
||||
);
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
() => ({
|
||||
paddingTop: insetsTop,
|
||||
@@ -562,30 +576,11 @@ function MobileSidebar({
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
pointerEvents="box-none"
|
||||
style={styles.mobileCloseButtonRow}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
<SidebarNavRows
|
||||
style={styles.sidebarHeaderGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -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 (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<HeaderToggleButton
|
||||
onPress={onPress}
|
||||
tooltipLabel={label}
|
||||
tooltipKeys={NO_SHORTCUT}
|
||||
tooltipSide="bottom"
|
||||
style={styles.mobileCloseButton}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<ThemedX
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
uniProps={mutedIconColorMapping}
|
||||
/>
|
||||
</HeaderToggleButton>
|
||||
</IconButtonChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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", () => {
|
||||
"> - \n\n[picture]: ../a.png",
|
||||
);
|
||||
});
|
||||
it("gives an image alone on its line its own paragraph", () => {
|
||||
expect(
|
||||
prepareMarkdownSource("Before the shot:\n\nAfter it.").render(),
|
||||
).toBe("Before the shot:\n\n\n\nAfter it.");
|
||||
expect(prepareMarkdownSource("\n").render()).toBe(
|
||||
"\n\n",
|
||||
);
|
||||
expect(
|
||||
prepareMarkdownSource(
|
||||
"Line one\\\n[](https://x)\nLine three",
|
||||
).render(),
|
||||
).toBe("Line one\n\n[](https://x)\n\nLine three");
|
||||
});
|
||||
it("keeps images that share a line with text inline", () => {
|
||||
const source = "Icon  in text\n 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> \n> More").render()).toBe(
|
||||
"> Quote text\n>\n> \n>\n> More",
|
||||
);
|
||||
expect(
|
||||
prepareMarkdownSource("- Item text\n \n More\n- Next").render(),
|
||||
).toBe("- Item text\n\n \n\n More\n- Next");
|
||||
expect(prepareMarkdownSource("- \n Caption line").render()).toBe(
|
||||
"- \n\n Caption line",
|
||||
);
|
||||
});
|
||||
it("renders own-line images as blocks through the library parser", async () => {
|
||||
const source =
|
||||
"Before:\n\nIcon  in text\n> Quoted\n> ";
|
||||
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||`.",
|
||||
|
||||
@@ -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<number>();
|
||||
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)
|
||||
|
||||
@@ -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<ViewStyle>;
|
||||
/** 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<View | null>(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 (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="header"
|
||||
fallbackAnchorRef={groupRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Row = BUILTIN_ROWS[item.id];
|
||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
||||
});
|
||||
|
||||
return (
|
||||
<View ref={groupRef} collapsable={false} style={style}>
|
||||
{visibleItems.map((item) => {
|
||||
if (item.kind === "plugin") {
|
||||
return (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="header"
|
||||
fallbackAnchorRef={groupRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Row = BUILTIN_ROWS[item.id];
|
||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
||||
})}
|
||||
{firstRowTrailing ? (
|
||||
<View style={styles.firstRow}>
|
||||
<View style={styles.firstRowItem}>{firstRow}</View>
|
||||
{firstRowTrailing}
|
||||
</View>
|
||||
) : (
|
||||
firstRow
|
||||
)}
|
||||
{restRows}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -179,3 +192,14 @@ const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps
|
||||
search: SidebarSearchRow,
|
||||
schedules: SidebarSchedulesRow,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
firstRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
firstRowItem: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1334,8 +1334,9 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "المشروع",
|
||||
base: "الأساس",
|
||||
branch: "الفرع",
|
||||
baseNotApplicable: "غير قابل للتطبيق",
|
||||
host: "المضيف",
|
||||
},
|
||||
titlePlaceholder: "العنوان (اختياري)",
|
||||
errors: {
|
||||
|
||||
@@ -1342,8 +1342,9 @@ export const en = {
|
||||
},
|
||||
fields: {
|
||||
project: "Project",
|
||||
base: "Base",
|
||||
branch: "Branch",
|
||||
baseNotApplicable: "Not applicable",
|
||||
host: "Host",
|
||||
},
|
||||
titlePlaceholder: "Title (optional)",
|
||||
errors: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1348,8 +1348,9 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "プロジェクト",
|
||||
base: "ベース",
|
||||
branch: "ブランチ",
|
||||
baseNotApplicable: "該当なし",
|
||||
host: "ホスト",
|
||||
},
|
||||
titlePlaceholder: "タイトル(任意)",
|
||||
errors: {
|
||||
|
||||
@@ -1341,8 +1341,9 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "프로젝트",
|
||||
base: "기준",
|
||||
branch: "브랜치",
|
||||
baseNotApplicable: "해당 없음",
|
||||
host: "호스트",
|
||||
},
|
||||
titlePlaceholder: "제목(선택 사항)",
|
||||
errors: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1352,8 +1352,9 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Проект",
|
||||
base: "Базовая ветка",
|
||||
branch: "Ветка",
|
||||
baseNotApplicable: "Неприменимо",
|
||||
host: "Хост",
|
||||
},
|
||||
titlePlaceholder: "Заголовок (необязательно)",
|
||||
errors: {
|
||||
|
||||
@@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "项目",
|
||||
base: "基线",
|
||||
branch: "分支",
|
||||
baseNotApplicable: "不适用",
|
||||
host: "主机",
|
||||
},
|
||||
titlePlaceholder: "标题(可选)",
|
||||
errors: {
|
||||
|
||||
@@ -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<void>((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);
|
||||
|
||||
@@ -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<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
projectsReady: Promise<void>,
|
||||
): Promise<void> {
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const chevron = <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 = (
|
||||
<>
|
||||
<View style={styles.iconBox}>{icon}</View>
|
||||
<Text style={variant === "field" ? styles.fieldValue : styles.value} numberOfLines={1}>
|
||||
{value}
|
||||
{detail ? <Text style={styles.detail}> {detail}</Text> : null}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
if (variant === "badge") {
|
||||
return (
|
||||
<>
|
||||
{valueLine}
|
||||
{chevron}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<View style={styles.fieldBody}>
|
||||
<Text style={styles.fieldLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.fieldValueLine}>{valueLine}</View>
|
||||
</View>
|
||||
{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<View, FieldTriggerProps>(function FieldTrigger(
|
||||
{ accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const style = useFieldTriggerStyle(content.variant, disabled);
|
||||
const trigger = (
|
||||
<ComboboxTrigger
|
||||
ref={ref}
|
||||
chevron={null}
|
||||
block={content.variant === "field"}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? content.label}
|
||||
>
|
||||
<FieldTriggerContent {...content} />
|
||||
</ComboboxTrigger>
|
||||
);
|
||||
if (content.variant === "field" || !tooltip) return trigger;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</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,
|
||||
},
|
||||
}));
|
||||
@@ -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 = <ThemedFolder size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const addProjectIcon = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const branchIcon = <ThemedGitBranch size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const pullRequestIcon = <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
|
||||
// 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 (
|
||||
<ProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderLabel.charAt(0).toUpperCase() || "?"}
|
||||
projectViewKey={projectViewKey}
|
||||
size={ICON_SIZE.md}
|
||||
textStyle={styles.fallbackText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
fallbackText: {
|
||||
fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
}));
|
||||
@@ -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<ComboboxProps["renderOption"]>;
|
||||
|
||||
interface PickerControl {
|
||||
anchorRef: RefObject<View | null>;
|
||||
open: () => void;
|
||||
openState: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface NewWorkspaceFormProps {
|
||||
isCompact: boolean;
|
||||
isPending: boolean;
|
||||
project: PickerControl & {
|
||||
options: ComboboxOption[];
|
||||
projectByOptionId: Map<string, HostProjectListItem>;
|
||||
iconDataByProjectViewKey: Map<string, string | null>;
|
||||
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<string, PickerItem>;
|
||||
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<RenderOption>(
|
||||
(row) => {
|
||||
const item = projectByOptionId.get(row.option.id);
|
||||
return (
|
||||
<ProjectOption
|
||||
{...row}
|
||||
project={item}
|
||||
iconDataUri={item ? (iconDataByProjectViewKey.get(item.viewKey) ?? null) : null}
|
||||
selectedServerId={selectedServerId}
|
||||
disabled={isPending || !item || !isProjectSelectable(item, supportsWorkspaceMultiplicity)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
iconDataByProjectViewKey,
|
||||
isPending,
|
||||
projectByOptionId,
|
||||
selectedServerId,
|
||||
supportsWorkspaceMultiplicity,
|
||||
],
|
||||
);
|
||||
const footer = useMemo(() => <AddProjectOption onPress={onAddProject} />, [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<ComboboxOption[]>(() => {
|
||||
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<RenderOption>(
|
||||
(row) => <IsolationOption {...row} disabled={isPending} />,
|
||||
[isPending],
|
||||
);
|
||||
return { options, onSelect: handleSelect, renderOption };
|
||||
}
|
||||
|
||||
function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolean): RenderOption {
|
||||
return useCallback<RenderOption>(
|
||||
(row) => <RefOption {...row} item={itemById.get(row.option.id)} disabled={isPending} />,
|
||||
[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 ? (
|
||||
<ProjectIcon
|
||||
projectViewKey={selectedProject.viewKey}
|
||||
projectName={selectedProject.projectName}
|
||||
iconDataUri={selectedProjectIconUri}
|
||||
/>
|
||||
) : (
|
||||
folderIcon
|
||||
),
|
||||
[selectedProject, selectedProjectIconUri],
|
||||
);
|
||||
const hostIcon = useMemo(
|
||||
() => <HostStatusDot serverId={host.selectedServerId} />,
|
||||
[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 = (
|
||||
<FieldTrigger
|
||||
ref={project.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.project")}
|
||||
accessibilityLabel="Workspace project"
|
||||
tooltip={t("newWorkspace.tooltips.project")}
|
||||
icon={projectIcon}
|
||||
value={project.triggerLabel}
|
||||
onPress={project.open}
|
||||
disabled={isPending}
|
||||
testID="new-workspace-project-picker-trigger"
|
||||
/>
|
||||
);
|
||||
const hostField = showHost ? (
|
||||
<HostPicker
|
||||
hosts={host.allHosts}
|
||||
value={host.selectedServerId}
|
||||
onSelect={host.onSelect}
|
||||
open={host.openState}
|
||||
onOpenChange={host.onOpenChange}
|
||||
anchorRef={host.anchorRef}
|
||||
includeAddHost={isCompact}
|
||||
onAddHost={host.onAddHost}
|
||||
searchable={false}
|
||||
title={t("newWorkspace.fields.host")}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={200}
|
||||
hostOptionTestID={hostOptionTestID}
|
||||
>
|
||||
<FieldTrigger
|
||||
ref={host.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.host")}
|
||||
tooltip={t("newWorkspace.tooltips.host")}
|
||||
icon={hostIcon}
|
||||
value={selectedHostLabel}
|
||||
onPress={host.open}
|
||||
disabled={isPending}
|
||||
testID="host-picker-trigger"
|
||||
/>
|
||||
</HostPicker>
|
||||
) : null;
|
||||
const isolationField = isolation.canCreateWorktree ? (
|
||||
<FieldTrigger
|
||||
ref={isolation.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.isolation.label")}
|
||||
accessibilityLabel="Workspace isolation"
|
||||
tooltip={t("newWorkspace.tooltips.isolation")}
|
||||
icon={isWorktree ? branchIcon : folderIcon}
|
||||
value={isolationValue}
|
||||
onPress={isolation.open}
|
||||
disabled={isPending}
|
||||
testID="workspace-create-isolation-trigger"
|
||||
/>
|
||||
) : null;
|
||||
const baseField = base.showRefPicker ? (
|
||||
<FieldTrigger
|
||||
ref={base.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.branch")}
|
||||
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
|
||||
tooltip={t("newWorkspace.tooltips.startingRef")}
|
||||
icon={refIcon}
|
||||
value={base.triggerLabel}
|
||||
onPress={base.open}
|
||||
disabled={isPending || !base.selectedSourceDirectory}
|
||||
testID="new-workspace-ref-picker-trigger"
|
||||
/>
|
||||
) : null;
|
||||
const launchField = (
|
||||
<LaunchControl
|
||||
variant={variant}
|
||||
serverId={launch.serverId}
|
||||
target={launch.target}
|
||||
onChange={launch.onChange}
|
||||
profiles={launch.profiles}
|
||||
disabled={launch.disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const pickers = (
|
||||
<>
|
||||
<Combobox
|
||||
options={project.options}
|
||||
value={project.selectedOptionId}
|
||||
onSelect={project.onSelect}
|
||||
searchable
|
||||
searchPlaceholder="Search projects"
|
||||
title={t("newWorkspace.fields.project")}
|
||||
open={project.openState}
|
||||
onOpenChange={project.onOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={360}
|
||||
anchorRef={project.anchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={projectPicker.renderOption}
|
||||
footer={projectPicker.footer}
|
||||
/>
|
||||
<Combobox
|
||||
options={isolationPicker.options}
|
||||
value={isolation.effectiveIsolation}
|
||||
onSelect={isolationPicker.onSelect}
|
||||
title={t("newWorkspace.isolation.label")}
|
||||
open={isolation.openState}
|
||||
onOpenChange={isolation.onOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={isolation.anchorRef}
|
||||
renderOption={isolationPicker.renderOption}
|
||||
/>
|
||||
<Combobox
|
||||
options={base.options}
|
||||
value={base.selectedOptionId}
|
||||
onSelect={base.onSelect}
|
||||
searchable
|
||||
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
|
||||
title={t("newWorkspace.refPicker.title")}
|
||||
open={base.openState}
|
||||
onOpenChange={base.onOpenChange}
|
||||
onSearchQueryChange={base.setSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={base.anchorRef}
|
||||
emptyText={base.emptyText}
|
||||
renderOption={renderRefOption}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.compactStack}>
|
||||
{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 : <View style={styles.reservedField} pointerEvents="none" />}
|
||||
{baseField ? null : <View style={styles.reservedField} pointerEvents="none" />}
|
||||
{pickers}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.desktopRow} pointerEvents="box-none">
|
||||
<View style={styles.desktopControl}>{projectField}</View>
|
||||
{hostField ? <View style={styles.desktopControl}>{hostField}</View> : null}
|
||||
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
|
||||
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
|
||||
<View style={styles.launchSpacer} pointerEvents="none" />
|
||||
{launchField}
|
||||
{pickers}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
+26
-46
@@ -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 = <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const blankTerminalIcon = <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
@@ -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<typeof Pressable>["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(
|
||||
() => <TriggerIcon target={target} profile={selectedProfile} />,
|
||||
[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 (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} compactMode="sheet">
|
||||
{/* 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. */}
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open && variant === "badge"}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
testID="new-workspace-launch-trigger"
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("newWorkspace.tooltips.launch")}
|
||||
>
|
||||
<View style={styles.iconBox}>
|
||||
<TriggerIcon target={target} profile={selectedProfile} />
|
||||
</View>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{triggerLabel}
|
||||
{selectedProfile ? (
|
||||
<Text style={styles.profileName}> {selectedProfile.name}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColorMapping} />
|
||||
<FieldTriggerContent
|
||||
variant={variant}
|
||||
label={t("newWorkspace.launch.title")}
|
||||
icon={triggerIcon}
|
||||
value={triggerLabel}
|
||||
detail={selectedProfile?.name}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
@@ -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,
|
||||
@@ -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 <View style={styles.iconBox}>{children}</View>;
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<IconBox>
|
||||
<ProjectIcon
|
||||
projectViewKey={project.viewKey}
|
||||
projectName={project.projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
/>
|
||||
</IconBox>
|
||||
) : undefined,
|
||||
[iconDataUri, project],
|
||||
);
|
||||
if (!project) return <View key={option.id} />;
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={`new-workspace-project-picker-option-${project.viewKey}`}
|
||||
label={project.projectName}
|
||||
description={
|
||||
getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir
|
||||
}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const openProjectKeys = useShortcutKeys("new-agent");
|
||||
const shortcut = useMemo(
|
||||
() => (openProjectKeys ? <Shortcut chord={openProjectKeys} /> : null),
|
||||
[openProjectKeys],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID="new-workspace-project-picker-add-project"
|
||||
label={t("sidebar.actions.addProject")}
|
||||
onPress={onPress}
|
||||
leadingSlot={addProjectIcon}
|
||||
trailingSlot={shortcut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const worktreeLeading = <IconBox>{branchIcon}</IconBox>;
|
||||
const localLeading = <IconBox>{folderIcon}</IconBox>;
|
||||
|
||||
export function IsolationOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
disabled,
|
||||
}: OptionRowState & { disabled: boolean }): ReactElement {
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={`workspace-create-isolation-${option.id}`}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={option.id === "worktree" ? worktreeLeading : localLeading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const branchLeading = <IconBox>{branchIcon}</IconBox>;
|
||||
const pullRequestLeading = <IconBox>{pullRequestIcon}</IconBox>;
|
||||
|
||||
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 ? <Text style={styles.divergenceLabel}>{divergenceLabel}</Text> : undefined,
|
||||
[divergenceLabel],
|
||||
);
|
||||
if (!item) return <View key={option.id} />;
|
||||
|
||||
const isBranch = item.kind === "branch";
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={
|
||||
isBranch
|
||||
? `new-workspace-ref-picker-branch-${item.name}`
|
||||
: `new-workspace-ref-picker-pr-${item.item.number}`
|
||||
}
|
||||
label={pickerItemLabel(item)}
|
||||
description={
|
||||
!isBranch && item.item.baseRefName
|
||||
? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
|
||||
: undefined
|
||||
}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={isBranch ? branchLeading : pullRequestLeading}
|
||||
trailingSlot={trailingSlot}
|
||||
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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"],
|
||||
},
|
||||
}));
|
||||
@@ -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 (
|
||||
<View style={compact ? styles.compact : styles.wide}>
|
||||
<View style={compact ? null : styles.wide}>
|
||||
<Button
|
||||
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
|
||||
variant="ghost"
|
||||
@@ -34,9 +34,6 @@ export function ImportSessionButton({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compact: {
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
|
||||
// border lines up with the composer's border.
|
||||
wide: {
|
||||
|
||||
@@ -18,7 +18,7 @@ const resolvePackageEntry = (packageName: string) => {
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
exclude: [...configDefaults.exclude, "e2e/**"],
|
||||
exclude: configDefaults.exclude,
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
@@ -27,12 +27,13 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
include: [
|
||||
"src/**/*.{test,spec}.{ts,tsx}",
|
||||
"e2e/support/helpers/*.test.ts",
|
||||
"native-release-version.test.ts",
|
||||
"app.config.test.ts",
|
||||
"vitest-environment.test.ts",
|
||||
],
|
||||
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
|
||||
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
exclude: [...configDefaults.exclude, "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,22 @@ import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli.js";
|
||||
|
||||
describe("canonical CLI surface", () => {
|
||||
it.each(["send", "agent send"])("%s accepts an explicit behavior override", async (path) => {
|
||||
const cli = createCli();
|
||||
const owner =
|
||||
path === "send" ? cli : cli.commands.find((command) => command.name() === "agent")!;
|
||||
const send = owner.commands.find((command) => command.name() === "send")!;
|
||||
send.action(() => {});
|
||||
await send.parseAsync(["target", "message", "--behavior", "interrupt"], { from: "user" });
|
||||
expect(send.opts().behavior).toBe("interrupt");
|
||||
await send.parseAsync(["target", "message", "--behavior", "steer"], { from: "user" });
|
||||
expect(send.opts().behavior).toBe("steer");
|
||||
send.exitOverride().configureOutput({ writeErr: () => {} });
|
||||
await expect(
|
||||
send.parseAsync(["target", "message", "--behavior", "queue"], { from: "user" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("offers daemon host selection as a global option", () => {
|
||||
expect(createCli().helpInformation()).toContain("--host <host>");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Command } from "commander";
|
||||
import { type Command, Option } from "commander";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
@@ -28,6 +29,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||
};
|
||||
|
||||
export interface AgentSendOptions extends CommandOptions {
|
||||
behavior?: ActiveTurnBehavior;
|
||||
wait?: boolean;
|
||||
image?: string[];
|
||||
prompt?: string;
|
||||
@@ -42,6 +44,12 @@ export function addSendOptions(cmd: Command): Command {
|
||||
.option("--prompt <text>", "Provide the message inline as a flag")
|
||||
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
|
||||
.option("--image <path>", "Attach image(s) to the message", collectMultiple, [])
|
||||
.addOption(
|
||||
new Option(
|
||||
"--behavior <behavior>",
|
||||
"Active-turn behavior (agent default: steer; otherwise: interrupt)",
|
||||
).choices(["interrupt", "steer"]),
|
||||
)
|
||||
.option("--no-wait", "Return immediately without waiting for completion");
|
||||
}
|
||||
|
||||
@@ -191,10 +199,12 @@ export async function runSendCommand(
|
||||
const images =
|
||||
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
|
||||
|
||||
const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined;
|
||||
// Send the message
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, {
|
||||
images,
|
||||
sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined,
|
||||
sourceAgentId,
|
||||
activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"),
|
||||
});
|
||||
|
||||
// If --no-wait, return immediately
|
||||
|
||||
@@ -402,6 +402,8 @@ export interface SetupFinishNotificationParams {
|
||||
childAgentId: string;
|
||||
callerAgentId: string;
|
||||
requireParentOwnership?: boolean;
|
||||
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
|
||||
dispatchResult?: Promise<PromptDispatchDisposition | false>;
|
||||
/** A replacement must observe its new start, not the displaced running snapshot. */
|
||||
waitForTurnStart?: boolean;
|
||||
logger: Logger;
|
||||
@@ -466,10 +468,17 @@ interface NotifySafelyOptions {
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
// A caller waits on a child through one armed notification. Arming again, such as a
|
||||
// follow-up prompt while the child still runs, replaces the earlier one so the child's
|
||||
// next finish reaches the caller once.
|
||||
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
|
||||
// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces
|
||||
// every earlier observation for the caller/child pair; a failure removes only itself.
|
||||
interface ArmedFinishNotification {
|
||||
stop(): void;
|
||||
supersedeWhen(accepted: Promise<boolean>): void;
|
||||
}
|
||||
|
||||
const armedFinishNotifications = new WeakMap<
|
||||
AgentManager,
|
||||
Map<string, Set<ArmedFinishNotification>>
|
||||
>();
|
||||
|
||||
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
|
||||
const {
|
||||
@@ -491,15 +500,49 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
|
||||
armedFinishNotifications.set(agentManager, armedByManager);
|
||||
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
|
||||
armedByManager.get(armedKey)?.();
|
||||
armedByManager.set(armedKey, stop);
|
||||
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
|
||||
armedByManager.set(armedKey, registrations);
|
||||
const previous = [...registrations];
|
||||
let superseded = Promise.resolve(false);
|
||||
let dispatchPending = Boolean(params.dispatchResult);
|
||||
const registration: ArmedFinishNotification = {
|
||||
stop,
|
||||
supersedeWhen(accepted) {
|
||||
superseded = superseded.then(
|
||||
async (alreadySuperseded) => alreadySuperseded || (await accepted),
|
||||
);
|
||||
},
|
||||
};
|
||||
registrations.add(registration);
|
||||
if (params.dispatchResult) {
|
||||
const dispatchAccepted = params.dispatchResult.then((result) => result !== false);
|
||||
for (const observer of previous) observer.supersedeWhen(dispatchAccepted);
|
||||
void params.dispatchResult.then((accepted) => {
|
||||
dispatchPending = false;
|
||||
if (accepted) {
|
||||
for (const observer of previous) observer.stop();
|
||||
if (stopped || accepted === "turn_started") stop();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
} else {
|
||||
for (const observer of previous) {
|
||||
observer.supersedeWhen(Promise.resolve(true));
|
||||
observer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unsubscribe?.();
|
||||
if (armedByManager.get(armedKey) === stop) {
|
||||
armedByManager.delete(armedKey);
|
||||
// Pending observations remain visible to overlapping sends until acknowledgement.
|
||||
if (!dispatchPending) {
|
||||
registrations.delete(registration);
|
||||
if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) {
|
||||
armedByManager.delete(armedKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +550,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
reason: FinishNotificationReason,
|
||||
permissionRequest?: AgentPermissionRequest,
|
||||
): Promise<void> {
|
||||
if (params.dispatchResult) {
|
||||
const result = await params.dispatchResult;
|
||||
if (result === false || result === "turn_started") return;
|
||||
}
|
||||
if (await superseded) return;
|
||||
const callerRecord = await agentStorage.get(callerAgentId);
|
||||
if (callerRecord?.archivedAt) {
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js";
|
||||
import { parseAgentMessage } from "./agent-messages/index.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -242,6 +243,7 @@ function buildAgentManagerSpies() {
|
||||
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
|
||||
tryRunOutOfBand: vi.fn().mockReturnValue(false),
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
@@ -3860,6 +3862,9 @@ const HELD_TURN_CAPABILITIES = {
|
||||
} as const;
|
||||
|
||||
interface HeldTurnOptions {
|
||||
finishDuringSteer?: boolean;
|
||||
rejectSteer?: boolean;
|
||||
steerResult?: "accepted" | "unavailable";
|
||||
/** Run the whole turn before the turn-start request returns. */
|
||||
finishTurnsDuringStart?: boolean;
|
||||
}
|
||||
@@ -3872,6 +3877,7 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
readonly capabilities = HELD_TURN_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
readonly prompts: string[] = [];
|
||||
readonly steeredPrompts: AgentPromptInput[] = [];
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private activeTurnId: string | null = null;
|
||||
private turnStartGate: Promise<void> | null = null;
|
||||
@@ -3904,6 +3910,14 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
return prompt === "/side-command" ? { run: async () => {} } : null;
|
||||
}
|
||||
|
||||
async steerActiveTurn(prompt: AgentPromptInput) {
|
||||
const status = this.options.steerResult ?? "unavailable";
|
||||
if (status === "accepted") this.steeredPrompts.push(prompt);
|
||||
if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn();
|
||||
if (this.options.rejectSteer) throw new Error("Steer rejected");
|
||||
return { status };
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
||||
await this.turnStartGate;
|
||||
@@ -4050,6 +4064,295 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
describe("send_agent_prompt MCP tool", () => {
|
||||
it.each([
|
||||
{ agentScoped: true, behavior: undefined, available: true, steered: true },
|
||||
{ agentScoped: true, behavior: "interrupt", available: true, steered: false },
|
||||
{ agentScoped: true, behavior: "steer", available: true, steered: true },
|
||||
{ agentScoped: false, behavior: undefined, available: true, steered: false },
|
||||
{ agentScoped: false, behavior: "steer", available: true, steered: true },
|
||||
{ agentScoped: true, behavior: undefined, available: false, steered: false },
|
||||
] as const)(
|
||||
"dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available",
|
||||
async ({ agentScoped, behavior, available, steered }) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: available ? "accepted" : "unavailable",
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: agentScoped ? parent.id : undefined,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "send_agent_prompt");
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
const originalTurn = manager.getAgent(child.id)!.activeTurnId;
|
||||
const response = await invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "New message",
|
||||
notifyOnFinish: false,
|
||||
...(behavior ? { activeTurnBehavior: behavior } : {}),
|
||||
});
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
disposition: steered ? "steered" : "turn_started",
|
||||
});
|
||||
expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2);
|
||||
expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0);
|
||||
expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered);
|
||||
} finally {
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ rejected: false, priorNotification: false },
|
||||
{ rejected: true, priorNotification: false },
|
||||
{ rejected: false, priorNotification: true },
|
||||
])(
|
||||
"buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)",
|
||||
async ({ rejected, priorNotification }) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: "accepted",
|
||||
finishDuringSteer: true,
|
||||
rejectSteer: rejected,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
let mcpClient: Client | undefined;
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
mcpClient = await connectInMemoryMcpClient(server);
|
||||
const send = (input: Record<string, unknown>) =>
|
||||
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: priorNotification,
|
||||
});
|
||||
const sending = send({
|
||||
agentId: child.id,
|
||||
prompt: "New message",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
if (rejected) {
|
||||
expect(await sending).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
|
||||
});
|
||||
// Flush asynchronous notification work before checking that a rejected
|
||||
// dispatch did not notify. Then prove a later successful send still can.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(client.sessions[0]!.prompts).toHaveLength(0);
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Retry after rejection",
|
||||
});
|
||||
client.sessions[1]!.finishTurn();
|
||||
} else {
|
||||
const response = await sending;
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
disposition: "steered",
|
||||
});
|
||||
}
|
||||
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
||||
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
||||
});
|
||||
} finally {
|
||||
await mcpClient?.close();
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves the original notification after rejection (completion during send: %s)",
|
||||
async (finishDuringSteer) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: "accepted",
|
||||
finishDuringSteer,
|
||||
rejectSteer: true,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
let mcpClient: Client | undefined;
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
mcpClient = await connectInMemoryMcpClient(server);
|
||||
const send = (input: Record<string, unknown>) =>
|
||||
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
const sending = send({
|
||||
agentId: child.id,
|
||||
prompt: "New message",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
expect(await sending).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
|
||||
});
|
||||
if (!finishDuringSteer) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(client.sessions[0]!.prompts).toHaveLength(0);
|
||||
client.sessions[1]!.finishTurn();
|
||||
}
|
||||
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
||||
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
||||
});
|
||||
} finally {
|
||||
await mcpClient?.close();
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, true],
|
||||
[true, false],
|
||||
[false, false],
|
||||
[true, true],
|
||||
])(
|
||||
"overlapping follow-ups notify once (first accepted: %s, second accepted: %s)",
|
||||
async (firstAccepted, secondAccepted) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: "accepted",
|
||||
finishDuringSteer: false,
|
||||
rejectSteer: true,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
let mcpClient: Client | undefined;
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
mcpClient = await connectInMemoryMcpClient(server);
|
||||
const send = (input: Record<string, unknown>) =>
|
||||
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
let firstResolve!: (result: PromptDispatchDisposition | false) => void;
|
||||
let secondResolve!: (result: PromptDispatchDisposition | false) => void;
|
||||
const first = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
||||
firstResolve = resolve;
|
||||
});
|
||||
const second = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
||||
secondResolve = resolve;
|
||||
});
|
||||
for (const dispatchResult of [first, second]) {
|
||||
setupFinishNotification({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
childAgentId: child.id,
|
||||
callerAgentId: parent.id,
|
||||
dispatchResult,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
firstResolve(firstAccepted ? "steered" : false);
|
||||
await first;
|
||||
secondResolve(secondAccepted ? "steered" : false);
|
||||
await second;
|
||||
client.sessions[1]!.finishTurn();
|
||||
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(client.sessions[0]!.prompts).toHaveLength(1);
|
||||
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
||||
});
|
||||
} finally {
|
||||
await mcpClient?.close();
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const logger = createTestLogger();
|
||||
const existingCwd = process.cwd();
|
||||
|
||||
@@ -4100,7 +4403,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
|
||||
const response = await tool.handler(parsed.data as Record<string, unknown>);
|
||||
|
||||
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(spies.agentManager.subscribe).toHaveBeenCalled();
|
||||
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
|
||||
expect(response.structuredContent.guidance).toBe(
|
||||
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
|
||||
@@ -4270,7 +4573,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["out_of_band", "startup_failure"] as const)(
|
||||
it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)(
|
||||
"preserves %s dispatch notification semantics",
|
||||
async (scenario) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-"));
|
||||
@@ -4306,10 +4609,11 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
prompt: "Ongoing task",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
if (scenario === "out_of_band") {
|
||||
if (scenario !== "startup_failure") {
|
||||
const response = await invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "/side-command",
|
||||
activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer",
|
||||
});
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
|
||||
@@ -1617,7 +1617,12 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
configId: "allow_all",
|
||||
value: "on",
|
||||
});
|
||||
expect(setSessionMode).not.toHaveBeenCalled();
|
||||
// Allow All is agent mode with permissions pre-granted, so it has to leave
|
||||
// whatever mode the session is in, not just flip the flag.
|
||||
expect(setSessionMode).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
});
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
|
||||
});
|
||||
@@ -1648,7 +1653,12 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
configId: "allow_all",
|
||||
value: "on",
|
||||
});
|
||||
expect(setSessionMode).not.toHaveBeenCalled();
|
||||
// Allow All is agent mode with permissions pre-granted, so it has to leave
|
||||
// whatever mode the session is in, not just flip the flag.
|
||||
expect(setSessionMode).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
});
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
});
|
||||
|
||||
|
||||
@@ -209,6 +209,10 @@ export async function writeCopilotProviderMode(
|
||||
if (!requestsAllowAll) {
|
||||
return { handled: false };
|
||||
}
|
||||
await context.connection.setSessionMode({
|
||||
sessionId: context.sessionId,
|
||||
modeId: COPILOT_AGENT_MODE_ID,
|
||||
});
|
||||
const response = await context.connection.setSessionConfigOption({
|
||||
sessionId: context.sessionId,
|
||||
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
||||
|
||||
import { isCommandAvailable } from "../../../executable-resolution/executable-resolution.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { resolveACPModeSelection } from "./acp-agent.js";
|
||||
import {
|
||||
COPILOT_ALLOW_ALL_MODE_ID,
|
||||
COPILOT_MODES,
|
||||
writeCopilotProviderMode,
|
||||
} from "./copilot-acp-agent.js";
|
||||
|
||||
const AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
||||
const PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
|
||||
|
||||
describe("Copilot ACP provider (real) Allow All mode", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await isCommandAvailable("copilot");
|
||||
});
|
||||
|
||||
test("selecting Allow All from Plan leaves the real provider in Agent mode", async (context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
const cwd = await mkdtemp(join(tmpdir(), "copilot-allow-all-real-"));
|
||||
const child = spawn("copilot", ["--acp"], { cwd, stdio: ["pipe", "pipe", "ignore"] });
|
||||
const connection = new ClientSideConnection(
|
||||
() => ({
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
}),
|
||||
ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)),
|
||||
);
|
||||
|
||||
try {
|
||||
await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} });
|
||||
const session = await connection.newSession({ cwd, mcpServers: [] });
|
||||
const plan = await connection.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: PLAN_MODE_ID,
|
||||
});
|
||||
expect(plan.configOptions).toContainEqual(
|
||||
expect.objectContaining({ id: "mode", currentValue: PLAN_MODE_ID }),
|
||||
);
|
||||
|
||||
// This is the same provider writer called by ACPAgentSession. Assert its
|
||||
// untransformed ACP response, since Paseo's displayed mode hides this bug.
|
||||
const result = await writeCopilotProviderMode({
|
||||
connection,
|
||||
sessionId: session.sessionId,
|
||||
requestedModeId: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
currentModeId: PLAN_MODE_ID,
|
||||
selection: resolveACPModeSelection({
|
||||
modeId: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
availableModes: COPILOT_MODES,
|
||||
configOptions: plan.configOptions,
|
||||
}),
|
||||
configOptions: plan.configOptions,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(result.configOptions).toContainEqual(
|
||||
expect.objectContaining({ id: "mode", currentValue: AGENT_MODE_ID }),
|
||||
);
|
||||
expect(result.configOptions).toContainEqual(
|
||||
expect.objectContaining({ id: "allow_all", currentValue: "on" }),
|
||||
);
|
||||
} finally {
|
||||
await terminateWithTreeKill(child, { gracefulTimeoutMs: 1000, forceTimeoutMs: 1000 });
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import type { AgentManager } from "../agent-manager.js";
|
||||
import { AgentProfileSchema } from "@getpaseo/protocol/messages";
|
||||
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||
import {
|
||||
AgentFeatureSchema,
|
||||
@@ -1209,6 +1209,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
.object(legacyTopLevelCreateAgentInputSchema)
|
||||
.strict();
|
||||
const commonSendAgentPromptInputSchema = {
|
||||
activeTurnBehavior: ActiveTurnBehaviorSchema.optional().describe(
|
||||
'How to send into a running turn: "steer" joins it, falling back to interrupt when unavailable; "interrupt" replaces it. Defaults to steer for agent callers and interrupt otherwise.',
|
||||
),
|
||||
agentId: z.string(),
|
||||
prompt: z.string(),
|
||||
sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."),
|
||||
@@ -1955,28 +1958,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
inputSchema: sendAgentPromptInputSchema,
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
disposition: z.enum(["turn_started", "out_of_band"]).optional(),
|
||||
disposition: z.enum(["turn_started", "out_of_band", "steered"]).optional(),
|
||||
status: AgentStatusEnum,
|
||||
guidance: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => {
|
||||
async ({
|
||||
agentId,
|
||||
prompt,
|
||||
sessionMode,
|
||||
notifyOnFinish = Boolean(callerAgentId),
|
||||
activeTurnBehavior = callerAgentId ? "steer" : "interrupt",
|
||||
}) => {
|
||||
let stopNotification: (() => void) | undefined;
|
||||
function armFinishNotification(disposition: PromptDispatchDisposition): void {
|
||||
if (!callerAgentId || !notifyOnFinish) {
|
||||
return;
|
||||
}
|
||||
let acceptDispatch!: (result: PromptDispatchDisposition | false) => void;
|
||||
const dispatchResult = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
||||
acceptDispatch = resolve;
|
||||
});
|
||||
const notifying = Boolean(callerAgentId && notifyOnFinish);
|
||||
if (callerAgentId && notifyOnFinish) {
|
||||
stopNotification = setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: agentId,
|
||||
callerAgentId,
|
||||
waitForTurnStart: disposition === "turn_started",
|
||||
waitForTurnStart: false,
|
||||
dispatchResult,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
function confirmDispatch(disposition: PromptDispatchDisposition): void {
|
||||
// A new turn must ignore outcomes from the displaced turn. Its iterator
|
||||
// has not started yet, so rearm here; steering keeps the early observer.
|
||||
if (disposition !== "turn_started") {
|
||||
acceptDispatch(disposition);
|
||||
return;
|
||||
}
|
||||
acceptDispatch(disposition);
|
||||
if (!callerAgentId || !notifyOnFinish) return;
|
||||
stopNotification?.();
|
||||
stopNotification = setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: agentId,
|
||||
callerAgentId,
|
||||
waitForTurnStart: true,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
const notifying = Boolean(callerAgentId && notifyOnFinish);
|
||||
const { disposition } = await sendPromptToAgent({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -1984,9 +2014,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
prompt,
|
||||
source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined,
|
||||
sessionMode,
|
||||
onDispatch: armFinishNotification,
|
||||
activeTurnBehavior,
|
||||
onDispatch: confirmDispatch,
|
||||
logger: childLogger,
|
||||
}).catch((error) => {
|
||||
acceptDispatch(false);
|
||||
stopNotification?.();
|
||||
if (error instanceof AgentNotFoundError) {
|
||||
throw new PaseoToolInputError(error.message);
|
||||
|
||||
+4
-1
@@ -289,9 +289,12 @@ Use the recipient's agent ID from `paseo ls`, or [copy it from the agent's tab](
|
||||
```bash
|
||||
paseo send <id> "now run the tests"
|
||||
paseo send <id> --image screenshot.png "what's wrong here?"
|
||||
paseo send <id> --no-wait "queue this task"
|
||||
paseo send <id> --no-wait "run this task"
|
||||
paseo send <id> --behavior interrupt "stop and do this instead"
|
||||
```
|
||||
|
||||
Agent-originated sends (`PASEO_AGENT_ID` set) default to steering the running turn. Other sends default to interrupting it. Use `--behavior steer` or `--behavior interrupt` to override the default. Steering uses the same fallback as the client: if steering is unavailable, the running turn is interrupted and replaced.
|
||||
|
||||
## Viewing logs
|
||||
|
||||
```bash
|
||||
|
||||
@@ -51,7 +51,7 @@ Agents can prompt each other by **agent ID**, the identifier Paseo uses to addre
|
||||
|
||||
> Use Paseo to send this prompt to agent [paste agent ID here]: “Review the parser changes in your workspace and report any missing test cases.”
|
||||
|
||||
The receiving agent gets the prompt in its existing conversation. To have it send a separate message back, give it the sender's agent ID too.
|
||||
The receiving agent gets the prompt in its existing conversation. Agent-originated sends default to steering a running turn, with interruption fallback when steering is unavailable. Set MCP `activeTurnBehavior` or CLI `--behavior` to `steer` or `interrupt` to override that default. To have it send a separate message back, give it the sender's agent ID too.
|
||||
|
||||
Underneath, the agent calls `send_agent_prompt` with the recipient's `agentId` and a `prompt`. Agents can also discover IDs with `list_agents`; the CLI uses [`paseo send <id>`](/docs/cli#sending-messages). For another host, use the [remote CLI workflow](#work-on-another-machine).
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c
|
||||
|
||||
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
|
||||
|
||||
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
|
||||
**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
|
||||
|
||||
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user