Show prompt titles immediately for new workspaces (#1567)

* Set workspace prompt titles at creation

* Cover existing workspace prompt titles

* Fix rewind E2E prompt selector
This commit is contained in:
Mohamed Boudra
2026-06-17 15:30:32 +08:00
committed by GitHub
parent 1340a80144
commit 0c9802b22b
6 changed files with 304 additions and 35 deletions
@@ -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<voi
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
function userMessage(page: Page, text: string): Locator {
return page.getByTestId("user-message").filter({ hasText: text });
}
async function expectUserMessageVisible(page: Page, text: string): Promise<void> {
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 {
@@ -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
);
}
@@ -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,
+71 -19
View File
@@ -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<void> {
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<void> {
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<PersistedWorkspaceRecord> {
private async resolveOrCreateWorkspaceIdForCreateAgent(input: {
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
requestedWorkspaceId?: string;
cwd: string;
initialTitle: string | null;
}): Promise<string> {
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<PersistedWorkspaceRecord> {
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,
@@ -132,7 +132,7 @@ interface SessionTestAccess {
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
applyGeneratedWorkspaceTitle(
workspaceId: string,
input: { title: string; branch?: string | null },
input: { title: string; branch?: string | null; promptTitle?: string | null },
): Promise<void>;
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<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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");
});
@@ -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;