From 0c9802b22b52c1e37979ea1d50f016d6767cefad Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 17 Jun 2026 15:30:32 +0800 Subject: [PATCH] Show prompt titles immediately for new workspaces (#1567) * Set workspace prompt titles at creation * Cover existing workspace prompt titles * Fix rewind E2E prompt selector --- .../app/e2e/rewind-menu.ui-contract.spec.ts | 33 +-- .../src/server/agent/create-agent-title.ts | 9 + .../src/server/paseo-worktree-service.ts | 4 + packages/server/src/server/session.ts | 90 ++++++-- .../src/server/session.workspaces.test.ts | 197 +++++++++++++++++- .../server/src/server/workspace-registry.ts | 6 +- 6 files changed, 304 insertions(+), 35 deletions(-) diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/rewind-menu.ui-contract.spec.ts index 51e8dc848..158f40ae1 100644 --- a/packages/app/e2e/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/rewind-menu.ui-contract.spec.ts @@ -1,3 +1,4 @@ +import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { @@ -14,6 +15,14 @@ async function expectUserMessageCount(page: Page, expected: number): Promise { + await expect(userMessage(page, text)).toBeVisible(); +} + test.describe("Rewind sheet", () => { test("rewinds from a user message sheet option", async ({ page }) => { const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn."; @@ -29,14 +38,14 @@ test.describe("Rewind sheet", () => { await openAgentRoute(page, session); await expectComposerVisible(page); - await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible(); + await expectUserMessageVisible(page, firstPrompt); await expectUserMessageCount(page, 1); await submitMessage(page, secondPrompt); - await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible(); + await expectUserMessageVisible(page, secondPrompt); await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); await expectUserMessageCount(page, 2); - await page.getByText(firstPrompt, { exact: true }).hover(); + await userMessage(page, firstPrompt).hover(); await page.getByTestId("rewind-menu-trigger").first().click(); const rewindSheet = page.getByTestId("rewind-menu-content"); await expect(rewindSheet).toBeVisible(); @@ -46,20 +55,20 @@ test.describe("Rewind sheet", () => { await page.getByTestId("rewind-menu-conversation").click(); await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0); - await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0); + await expect(userMessage(page, secondPrompt)).toHaveCount(0); await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0); await expectUserMessageCount(page, 1); await expectComposerDraft(page, firstPrompt); await submitMessage(page, replacementPrompt); - await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible(); - await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0); + await expectUserMessageVisible(page, replacementPrompt); + await expect(userMessage(page, secondPrompt)).toHaveCount(0); await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0); await expectUserMessageCount(page, 2); await fillComposerDraft(page, ""); await composerLocator(page).evaluate((element) => element.blur()); - await page.getByText(replacementPrompt, { exact: true }).hover(); + await userMessage(page, replacementPrompt).hover(); await page.getByTestId("rewind-menu-trigger").last().click(); await expect(page.getByTestId("rewind-menu-content")).toBeVisible(); await page.getByTestId("rewind-menu-files").click(); @@ -70,7 +79,7 @@ test.describe("Rewind sheet", () => { const preservedDraft = "Keep this human draft after rewind."; await fillComposerDraft(page, preservedDraft); await composerLocator(page).evaluate((element) => element.blur()); - await page.getByText(replacementPrompt, { exact: true }).hover(); + await userMessage(page, replacementPrompt).hover(); await page.getByTestId("rewind-menu-trigger").last().click(); await expect(page.getByTestId("rewind-menu-content")).toBeVisible(); await page.getByTestId("rewind-menu-files").click(); @@ -80,7 +89,7 @@ test.describe("Rewind sheet", () => { await fillComposerDraft(page, ""); await composerLocator(page).evaluate((element) => element.blur()); - await page.getByText(replacementPrompt, { exact: true }).hover(); + await userMessage(page, replacementPrompt).hover(); await page.getByTestId("rewind-menu-trigger").last().click(); await expect(page.getByTestId("rewind-menu-content")).toBeVisible(); await page.getByTestId("rewind-menu-both").click(); @@ -108,9 +117,9 @@ test.describe("Rewind sheet", () => { await openAgentRoute(page, session); await expectComposerVisible(page); - await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible(); + await expectUserMessageVisible(page, firstPrompt); - await page.getByText(firstPrompt, { exact: true }).hover(); + await userMessage(page, firstPrompt).hover(); await page.getByTestId("rewind-menu-trigger").first().click(); const rewindSheet = page.getByTestId("rewind-menu-content"); await expect(rewindSheet).toBeVisible(); @@ -122,7 +131,7 @@ test.describe("Rewind sheet", () => { await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError); await expect(page.getByText("Uncaught Error")).toHaveCount(0); - await page.getByText(firstPrompt, { exact: true }).hover(); + await userMessage(page, firstPrompt).hover(); await page.getByTestId("rewind-menu-trigger").first().click(); await expect(page.getByTestId("rewind-menu-content")).toBeVisible(); } finally { diff --git a/packages/server/src/server/agent/create-agent-title.ts b/packages/server/src/server/agent/create-agent-title.ts index 0922cbe8f..ab0cc7f67 100644 --- a/packages/server/src/server/agent/create-agent-title.ts +++ b/packages/server/src/server/agent/create-agent-title.ts @@ -1,4 +1,5 @@ import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits"; +import type { FirstAgentContext } from "@getpaseo/protocol/messages"; const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); @@ -35,3 +36,11 @@ export function resolveCreateAgentTitles(options: { provisionalTitle, }; } + +export function resolveFirstAgentPromptTitle(firstAgentContext?: FirstAgentContext): string | null { + return ( + resolveCreateAgentTitles({ + initialPrompt: firstAgentContext?.prompt, + }).provisionalTitle ?? null + ); +} diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 59ce61c48..9be866f05 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -25,6 +25,7 @@ import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata, } from "../utils/worktree-metadata.js"; import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent.js"; +import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; @@ -70,6 +71,7 @@ export async function createPaseoWorktree( projectId: input.projectId, repoRoot: createdWorktree.repoRoot, worktree: createdWorktree.worktree, + title: resolveFirstAgentPromptTitle(input.firstAgentContext), deps, }); @@ -200,6 +202,7 @@ async function upsertWorkspaceForWorktree(options: { projectId?: string; repoRoot: string; worktree: WorktreeConfig; + title?: string | null; deps: Pick< CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry" | "workspaceGitService" @@ -241,6 +244,7 @@ async function upsertWorkspaceForWorktree(options: { kind: "worktree", displayName: options.worktree.branchName || normalizedCwd, branch: options.worktree.branchName || null, + title: options.title ?? null, createdAt: now, updatedAt: now, archivedAt: null, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 086c26fc5..b40ad7e26 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -73,7 +73,10 @@ import { waitForAgentRunStartWithTimeout, unarchiveAgentState, } from "./agent/agent-prompt.js"; -import { resolveCreateAgentTitles } from "./agent/create-agent-title.js"; +import { + resolveCreateAgentTitles, + resolveFirstAgentPromptTitle, +} from "./agent/create-agent-title.js"; import { respondToAgentPermission } from "./agent/permission-response.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -3234,6 +3237,7 @@ export class Session { ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), }; + const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext); const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({ cwd: config.cwd, target: worktree, @@ -3244,13 +3248,12 @@ export class Session { const createAgentConfig: AgentSessionConfig = createdWorktree ? { ...config, cwd: createdWorktree.worktree.worktreePath } : config; - // Ownership comes from an explicit id (worktree or request). An agent - // created with no explicit workspace mints a fresh one — we never resolve - // an existing workspace by cwd, because many workspaces may share a cwd. - const workspaceId = - createdWorktree?.workspace.workspaceId ?? - msg.workspaceId ?? - (await this.createWorkspaceForDirectory(createAgentConfig.cwd)).workspaceId; + const workspaceId = await this.resolveOrCreateWorkspaceIdForCreateAgent({ + createdWorktree, + requestedWorkspaceId: msg.workspaceId, + cwd: createAgentConfig.cwd, + initialTitle: workspacePromptTitle, + }); const { snapshot, liveSnapshot } = await createAgentCommand( { @@ -3281,6 +3284,9 @@ export class Session { }, ); createdAgentId = snapshot.id; + if (!createdWorktree && msg.workspaceId) { + await this.writeInitialWorkspaceTitleIfUntitled(workspaceId, workspacePromptTitle); + } await this.forwardAgentUpdate(snapshot); if (!createdWorktree && trimmedPrompt) { await this.scheduleAutoNameLocalWorkspaceTitleForFirstAgent({ @@ -3711,31 +3717,52 @@ export class Session { await this.applyGeneratedWorkspaceTitle(input.workspace.workspaceId, { title: generatedTitle, branch: result.branchName, + promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext), }); await this.notifyGitMutation(input.workspace.cwd, "rename-branch"); await this.emitWorkspaceUpdateForCwd(input.workspace.cwd); } - // applyGeneratedWorkspaceTitle fills the generated title only while the - // workspace is still untitled. It re-reads the current record from the - // registry so concurrent upserts that happened after workspace creation are - // not clobbered, while still persisting branch metadata from the rename path. + // Generated names may replace the prompt title set at creation, but not a user + // rename that landed while the async generator was running. private async applyGeneratedWorkspaceTitle( workspaceId: string, - input: { title: string; branch?: string | null }, + input: { title: string; branch?: string | null; promptTitle?: string | null }, ): Promise { const current = await this.workspaceRegistry.get(workspaceId); if (!current) { return; } + let title = current.title; + if (!title || (input.promptTitle && title === input.promptTitle)) { + title = input.title; + } await this.workspaceRegistry.upsert({ ...current, - title: current.title || input.title, + title, ...(input.branch ? { branch: input.branch } : {}), updatedAt: new Date().toISOString(), }); } + private async writeInitialWorkspaceTitleIfUntitled( + workspaceId: string, + title: string | null, + ): Promise { + if (!title) { + return; + } + const current = await this.workspaceRegistry.get(workspaceId); + if (!current || current.title) { + return; + } + await this.workspaceRegistry.upsert({ + ...current, + title, + updatedAt: new Date().toISOString(), + }); + } + // Wraps the injected workspace-name generator for a directory workspace. private async generateWorkspaceTitleFromContext(input: { cwd: string; @@ -3754,8 +3781,7 @@ export class Session { } // Generates a human title for a directory workspace from the firstAgentContext - // prompt and writes it as displayName. No branch rename — directory workspaces - // have no worktree git state. + // prompt. No branch rename — directory workspaces have no worktree git state. // TODO(K7): same-dir directory-workspace display disambiguation not yet implemented. private async maybeAutoNameDirectoryWorkspaceTitle(input: { workspaceId: string; @@ -3772,7 +3798,10 @@ export class Session { } // K4: applyGeneratedWorkspaceTitle re-reads from the registry before writing. // Directory workspaces have no branch — write only the title. - await this.applyGeneratedWorkspaceTitle(input.workspaceId, { title }); + await this.applyGeneratedWorkspaceTitle(input.workspaceId, { + title, + promptTitle: resolveFirstAgentPromptTitle(input.firstAgentContext), + }); await this.emitWorkspaceUpdateForWorkspaceId(input.workspaceId); } @@ -6910,7 +6939,27 @@ export class Session { return this.createWorkspaceForDirectory(normalizedCwd); } - private async createWorkspaceForDirectory(cwd: string): Promise { + private async resolveOrCreateWorkspaceIdForCreateAgent(input: { + createdWorktree: CreatePaseoWorktreeWorkflowResult | null; + requestedWorkspaceId?: string; + cwd: string; + initialTitle: string | null; + }): Promise { + if (input.createdWorktree) { + return input.createdWorktree.workspace.workspaceId; + } + + if (input.requestedWorkspaceId) { + return input.requestedWorkspaceId; + } + + return (await this.createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId; + } + + private async createWorkspaceForDirectory( + cwd: string, + title?: string | null, + ): Promise { const checkout = await this.workspaceGitService.getCheckout(cwd); const membership = classifyDirectoryForProjectMembership({ cwd, checkout }); const timestamp = new Date().toISOString(); @@ -6927,6 +6976,7 @@ export class Session { cwd, kind: membership.workspaceKind, displayName: membership.workspaceDisplayName, + title: title ?? null, createdAt: timestamp, updatedAt: timestamp, }); @@ -7588,8 +7638,10 @@ export class Session { return; } + const explicitTitle = request.title?.trim() || null; + const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext); const workspace = await createLocalCheckoutWorkspace( - { cwd, title: request.title ?? null }, + { cwd, title: explicitTitle ?? promptTitle }, { projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 1b8693773..33467e501 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -132,7 +132,7 @@ interface SessionTestAccess { emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; applyGeneratedWorkspaceTitle( workspaceId: string, - input: { title: string; branch?: string | null }, + input: { title: string; branch?: string | null; promptTitle?: string | null }, ): Promise; emit(message: unknown): void; onMessage(message: unknown): void; @@ -791,6 +791,122 @@ test("create_agent_request keeps requested child cwd when grouped under an exist } }); +test("create_agent_request writes the first prompt title onto an untitled existing workspace", async () => { + const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-")); + try { + const cwd = path.join(workdir, "repo"); + mkdirSync(cwd, { recursive: true }); + + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); + const agentManager = new AgentManager({ + clients: { codex: new CreateAgentTestClient() }, + registry: agentStorage, + logger: asSessionLogger(logger), + idFactory: () => "00000000-0000-4000-8000-000000000552", + }); + const projectRegistry = new FileBackedProjectRegistry( + path.join(workdir, "projects.json"), + asSessionLogger(logger), + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(workdir, "workspaces.json"), + asSessionLogger(logger), + ); + + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "proj-existing", + rootPath: cwd, + kind: "git", + displayName: "repo", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "ws-existing", + projectId: "proj-existing", + cwd, + kind: "local_checkout", + displayName: "repo", + title: null, + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + + const session = asTestSession( + new Session({ + clientId: "test-client", + appVersion: null, + onMessage: vi.fn(), + logger: asSessionLogger(logger), + downloadTokenStore: asDownloadTokenStore(), + pushTokenStore: asPushTokenStore(), + paseoHome: path.join(workdir, "paseo-home"), + agentManager, + agentStorage, + projectRegistry, + workspaceRegistry, + chatService: asChatService(), + scheduleService: asScheduleService(), + loopService: asLoopService(), + checkoutDiffManager: asCheckoutDiffManager({ + subscribe: async () => ({ + initial: { cwd, files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + }), + workspaceGitService: createNoopWorkspaceGitService(), + daemonConfigStore: asDaemonConfigStore({ + get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), + onChange: () => () => {}, + }), + mcpBaseUrl: null, + stt: null, + tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + terminalManager: null, + }), + ); + + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-existing-title", + workspaceId: "ws-existing", + config: { provider: "codex", cwd }, + initialPrompt: "Fix login bug\nwith better validation", + attachments: [], + }); + + const [createdAgent] = agentManager.listAgents(); + expect(createdAgent?.workspaceId).toBe("ws-existing"); + await expect(workspaceRegistry.get("ws-existing")).resolves.toMatchObject({ + title: "Fix login bug", + }); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => { const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const storedRecord = { @@ -6266,6 +6382,46 @@ test("applyGeneratedWorkspaceTitle writes branch metadata and does not clobber c expect(saved?.title).toBe("User-set title"); }); +test("applyGeneratedWorkspaceTitle replaces the unchanged prompt title", async () => { + const session = createSessionForWorkspaceTests(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-prompt-title", + projectId: "proj-prompt-title", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + title: "Fix login bug", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const stored = new Map([[workspace.workspaceId, workspace]]); + session.workspaceRegistry.get = async (id: string) => stored.get(id) ?? null; + session.workspaceRegistry.upsert = async (record: unknown) => { + const parsed = record as typeof workspace; + stored.set(parsed.workspaceId, parsed); + }; + + await session.applyGeneratedWorkspaceTitle(workspace.workspaceId, { + title: "Generated login fix", + promptTitle: "Fix login bug", + }); + + expect(stored.get(workspace.workspaceId)?.title).toBe("Generated login fix"); + + stored.set(workspace.workspaceId, { + ...workspace, + title: "User rename", + updatedAt: "2026-03-01T12:01:00.000Z", + }); + + await session.applyGeneratedWorkspaceTitle(workspace.workspaceId, { + title: "Generated login fix", + promptTitle: "Fix login bug", + }); + + expect(stored.get(workspace.workspaceId)?.title).toBe("User rename"); +}); + // Phase 7: branch is a git fact derived per-descriptor from each workspace's own // live git snapshot, and reconciliation re-persists `branch` per workspace from // its own cwd. handleCheckoutRenameBranchRequest renames the git branch and @@ -6327,3 +6483,42 @@ test("checkout.rename_branch.request renames the branch without a denormalized b expect(persisted?.displayName).toBe("Refactor auth flow"); expect(persisted?.title).toBe("Refactor auth flow"); }); + +test("workspace.create.response persists the first prompt as the initial title", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = new Map>(); + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, + upsert: async (workspace) => { + workspaces.set(workspace.workspaceId, workspace); + }, + archive: async () => {}, + remove: async () => {}, + }, + }); + session.listAgentPayloads = async () => []; + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-create-first-prompt", + source: { kind: "directory", path: REPO_CWD }, + firstAgentContext: { + prompt: "Add retries to the payments flow\nwith exponential backoff", + }, + }); + + const response = findByType(emitted, "workspace.create.response"); + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.title).toBe("Add retries to the payments flow"); + expect(response?.payload.workspace?.name).toBe("Add retries to the payments flow"); + + const workspaceId = response?.payload.workspace?.id; + expect(workspaceId).toBeDefined(); + const persisted = await session.workspaceRegistry.get(workspaceId as string); + expect(persisted?.title).toBe("Add retries to the payments flow"); +}); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 507b08e37..56ca688dc 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -257,9 +257,9 @@ export function createPersistedWorkspaceRecord(input: { }); } -// The single workspace-name rule: the user-set title always wins; otherwise fall -// back to the freshest available derived display name (a live branch snapshot when -// the caller has one, the persisted displayName otherwise). +// The single workspace-name rule: the title always wins; otherwise fall back to +// the freshest available derived display name (a live branch snapshot when the +// caller has one, the persisted displayName otherwise). export function resolveWorkspaceName(input: { title: string | null; derivedDisplayName: string;