Merge branch 'draft-status-controls-refactor'

This commit is contained in:
Mohamed Boudra
2026-03-06 16:25:11 +07:00
10 changed files with 318 additions and 34 deletions

View File

@@ -0,0 +1,219 @@
import { test, expect } from './fixtures'
import type { Page } from '@playwright/test'
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
import { createTempGitRepo } from './helpers/workspace'
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
const url = new URL(rawUrl)
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
const workspaceToken = pathMatch?.[1] ?? null
let agentId = pathMatch?.[2] ?? null
if (!agentId) {
const open = url.searchParams.get('open')
const openMatch = open?.match(/^agent:(.+)$/) ?? null
if (openMatch?.[1]) {
agentId = openMatch[1]
}
}
return { workspaceToken, agentId }
}
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
const url = new URL(rawUrl)
const open = url.searchParams.get('open')
const openMatch = open?.match(/^agent:(.+)$/) ?? null
return openMatch?.[1] ?? null
}
async function preferSlowerThinkingOption(page: Page): Promise<void> {
await page.keyboard.press('Escape').catch(() => undefined)
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return
}
await thinkingTrigger.click({ force: true })
const menu = page.getByTestId('agent-thinking-menu').first()
if (!(await menu.isVisible().catch(() => false))) {
return
}
const preferred = ['high', 'max', 'deep', 'long', 'medium']
for (const label of preferred) {
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true })
await expect(menu).not.toBeVisible({ timeout: 5_000 })
return
}
}
const options = menu.getByRole('button')
const count = await options.count()
if (count > 0) {
await options.last().click({ force: true })
}
await expect(menu).not.toBeVisible({ timeout: 5_000 })
}
async function isAnyStopVisible(page: Page): Promise<boolean> {
const candidates = page.getByRole('button', { name: /stop|cancel/i })
const count = await candidates.count()
for (let index = 0; index < count; index += 1) {
if (await candidates.nth(index).isVisible().catch(() => false)) {
return true
}
}
return false
}
async function isRunningControlVisible(page: Page): Promise<boolean> {
if (await isAnyStopVisible(page)) {
return true
}
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
const count = await interruptSendButton.count()
for (let index = 0; index < count; index += 1) {
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
return true
}
}
return false
}
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
const locator = page.getByText(matcher).first()
const matches = page.getByText(matcher)
const count = await matches.count()
for (let index = 0; index < count; index += 1) {
if (await matches.nth(index).isVisible().catch(() => false)) {
return true
}
}
return await locator.isVisible().catch(() => false)
}
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
page,
}) => {
test.setTimeout(360_000)
const serverId = process.env.E2E_SERVER_ID
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.')
}
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
const id = `${Date.now()}`
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
const seedToken = `ACK_SEED_${id}`
const secondToken = `ACK_SECOND_${id}`
expect(seedPrompt).not.toBe(secondPrompt)
expect(seedToken).not.toBe(secondToken)
try {
await gotoHome(page)
await ensureHostSelected(page)
await page.goto(`/h/${serverId}/new-agent`)
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
// 1) Seed workspace with an existing agent.
await setWorkingDirectory(page, repo.path)
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await seedComposer.fill(seedPrompt)
await seedComposer.press('Enter')
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
const seededContext = parseWorkspaceContextFromUrl(page.url())
if (!seededContext.workspaceToken || !seededContext.agentId) {
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
}
const seededAgentId = seededContext.agentId
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
// 2) Use global New Agent entry and 3) select same workspace.
await page.getByText('New agent', { exact: true }).first().click()
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
await setWorkingDirectory(page, repo.path)
await preferSlowerThinkingOption(page)
await page.keyboard.press('Escape').catch(() => undefined)
// 4) Create second agent from global draft.
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await composer.fill(secondPrompt)
await composer.press('Enter')
await page.waitForTimeout(500)
if (page.url().includes('/new-agent')) {
const sendButton = page.getByRole('button', { name: /send message/i }).first()
await expect(sendButton).toBeVisible({ timeout: 10_000 })
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
await sendButton.click({ force: true })
}
// 5) Assert redirect workspace context + created agent tab is active/open.
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
const createdContext = parseWorkspaceContextFromUrl(page.url())
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
if (!createdContext.agentId) {
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
}
const createdAgentId = createdContext.agentId
expect(createdAgentId).toBeTruthy()
expect(createdAgentId).not.toBe(seededAgentId)
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
await expect
.poll(async () => {
const finalTabIds = await getWorkspaceTabTestIds(page)
return finalTabIds.includes(createdTabTestId)
})
.toBe(true)
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
// 6) Response assertions are scoped to created-agent active context.
await expect
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
.toBe(true)
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
// 7) Lifecycle assertions on created agent only: running first, then idle.
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await lifecycleComposer.fill(lifecyclePrompt)
await lifecycleComposer.press('Enter')
await expect
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
.toBe(true)
let sawRunning = false
for (let attempt = 0; attempt < 1200; attempt += 1) {
if (await isRunningControlVisible(page)) {
sawRunning = true
break
}
await page.waitForTimeout(50)
}
expect(sawRunning).toBe(true)
await expect
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
.toBe(true)
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
} finally {
await repo.cleanup()
}
})

View File

@@ -20,6 +20,7 @@ export const createTempGitRepo = async (
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });

View File

@@ -1,6 +1,6 @@
import "@/styles/unistyles";
import { polyfillCrypto } from "@/polyfills/crypto";
import { Stack, usePathname, useRouter } from "expo-router";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -47,7 +47,7 @@ import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
@@ -376,6 +376,7 @@ function OfferLinkListener({
function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
useFaviconStatus();
// Parse selectedAgentKey directly from pathname
@@ -383,7 +384,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const selectedAgentKey = useMemo(() => {
const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/);
const workspaceServerId = workspaceMatch?.[1]?.trim() ?? "";
const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname);
const openValue = Array.isArray(params.open) ? params.open[0] : params.open;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (workspaceServerId && openIntent?.kind === "agent") {
const agentId = openIntent.agentId.trim();
return agentId ? `${workspaceServerId}:${agentId}` : undefined;
@@ -391,7 +393,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const match = parseHostAgentRouteFromPathname(pathname);
return match ? `${match.serverId}:${match.agentId}` : undefined;
}, [pathname]);
}, [params.open, pathname]);
return (
<AppContainer selectedAgentId={selectedAgentKey}>{children}</AppContainer>

View File

@@ -1,6 +1,5 @@
import { useEffect } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
@@ -13,11 +12,7 @@ const HOST_ROOT_REDIRECT_DELAY_MS = 300;
export default function HostIndexRoute() {
const router = useRouter();
const routerPathname = usePathname();
const pathname =
Platform.OS === "web" && typeof window !== "undefined"
? window.location.pathname
: routerPathname;
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
@@ -37,11 +32,8 @@ export default function HostIndexRoute() {
return;
}
const timer = setTimeout(() => {
if (Platform.OS === "web" && typeof window !== "undefined") {
const currentPathname = window.location.pathname;
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
return;
}
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
return;
}
const visibleAgents = sessionAgents

View File

@@ -1,17 +1,18 @@
import { usePathname } from 'expo-router'
import { useGlobalSearchParams, usePathname } from 'expo-router'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
parseHostWorkspaceRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseWorkspaceOpenIntent,
} from '@/utils/host-routes'
export default function HostWorkspaceLayout() {
const expoPathname = usePathname()
const resolvedPathname = expoPathname
const activeRoute = parseHostWorkspaceRouteFromPathname(resolvedPathname)
const params = useGlobalSearchParams<{ open?: string | string[] }>()
const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname)
const serverId = activeRoute?.serverId ?? ''
const workspaceId = activeRoute?.workspaceId ?? ''
const openIntent = parseHostWorkspaceOpenIntentFromPathname(resolvedPathname)
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
const openIntent = parseWorkspaceOpenIntent(openValue)
return (
<WorkspaceScreen

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, Platform, View } from "react-native";
import { ActivityIndicator, View } from "react-native";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
@@ -9,11 +9,7 @@ import { buildHostRootRoute } from "@/utils/host-routes";
export default function Index() {
const router = useRouter();
const routerPathname = usePathname();
const pathname =
Platform.OS === "web" && typeof window !== "undefined"
? window.location.pathname
: routerPathname;
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();

View File

@@ -36,11 +36,7 @@ export function useKeyboardShortcuts({
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const routerPathname = usePathname();
const pathname =
Platform.OS === "web" && typeof window !== "undefined"
? window.location.pathname
: routerPathname;
const pathname = usePathname();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
useEffect(() => {
@@ -345,7 +341,6 @@ export function useKeyboardShortcuts({
pathname,
resetModifiers,
router,
routerPathname,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,

View File

@@ -576,8 +576,18 @@ function WorkspaceScreenContent({
tabs: uiTabs,
tabOrder,
focusedTabId,
preferredTarget:
openIntent?.kind === "agent"
? { kind: "agent", agentId: openIntent.agentId }
: openIntent?.kind === "terminal"
? { kind: "terminal", terminalId: openIntent.terminalId }
: openIntent?.kind === "draft"
? { kind: "draft", draftId: openIntent.draftId }
: openIntent?.kind === "file"
? { kind: "file", path: openIntent.path }
: null,
}),
[focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents]
[focusedTabId, openIntent, tabOrder, terminals, uiTabs, workspaceAgents]
);
const activeTabId = tabModel.activeTabId;

View File

@@ -151,6 +151,23 @@ describe("deriveWorkspaceTabModel", () => {
).toBe("agent_agent-b");
});
it("prefers the route-selected target over stale focused tab state", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [],
tabs: [
{ tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
{ tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 },
],
tabOrder: ["agent_agent-a", "agent_agent-b"],
focusedTabId: "agent_agent-a",
preferredTarget: { kind: "agent", agentId: "agent-b" },
});
expect(model.activeTabId).toBe("agent_agent-b");
expect(model.activeTab?.target).toEqual({ kind: "agent", agentId: "agent-b" });
});
it("re-resolves active content for a new workspace when prior focused tab is not available", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
@@ -237,4 +254,24 @@ describe("deriveWorkspaceTabModel", () => {
expect(upgradedDescriptor.label).toBe("Ready title");
}
});
it("prefers a retargeted tab when the route selects its upgraded agent target", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
terminals: [],
tabs: [
{
tabId: "draft_abc",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
],
tabOrder: ["draft_abc"],
focusedTabId: "some_other_tab",
preferredTarget: { kind: "agent", agentId: "agent-1" },
});
expect(model.activeTabId).toBe("draft_abc");
expect(model.activeTab?.descriptor.tabId).toBe("draft_abc");
});
});

View File

@@ -26,6 +26,25 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
if (left.kind !== right.kind) {
return false;
}
if (left.kind === "draft" && right.kind === "draft") {
return left.draftId === right.draftId;
}
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "file" && right.kind === "file") {
return left.path === right.path;
}
return false;
}
function formatProviderLabel(provider: Agent["provider"]): string {
if (provider === "claude") {
return "Claude";
@@ -130,6 +149,7 @@ export function deriveWorkspaceTabModel(input: {
tabs: WorkspaceTab[];
tabOrder: string[];
focusedTabId?: string | null;
preferredTarget?: WorkspaceTabTarget | null;
}): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>();
const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent]));
@@ -234,9 +254,20 @@ export function deriveWorkspaceTabModel(input: {
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
const focusedTabId = trimNonEmpty(input.focusedTabId);
const preferredTarget = input.preferredTarget ?? null;
const preferredTabId = (() => {
if (!preferredTarget) {
return null;
}
const matchingTab =
tabs.find((tab) => tabTargetsEqual(tab.target, preferredTarget)) ?? null;
return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget);
})();
const activeTabId =
focusedTabId && openTabIds.has(focusedTabId)
preferredTabId && openTabIds.has(preferredTabId)
? preferredTabId
: focusedTabId && openTabIds.has(focusedTabId)
? focusedTabId
: tabs[0]?.descriptor.tabId ?? null;