diff --git a/.changeset/name-forked-sessions.md b/.changeset/name-forked-sessions.md new file mode 100644 index 0000000..1209883 --- /dev/null +++ b/.changeset/name-forked-sessions.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Name newly forked and cloned web sessions with readable Fork and Copy counters based on the source session title. diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index a4fb1b9..96386bc 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -204,6 +204,7 @@ export class PiSessionService { this.publishStatus(session); }, }, + { listSessionNames: (cwd) => this.listSessionNames(cwd) }, ); } @@ -476,6 +477,20 @@ export class PiSessionService { }; } + private async listSessionNames(cwd: string): Promise { + const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]); + const names = new Set(); + for (const session of sessions) addSessionName(names, session.name); + for (const record of archivedRecords) { + if (record.cwd === cwd) addSessionName(names, record.name); + } + for (const active of new Set(this.active.values())) { + const session = active.runtime.session; + if (session.sessionManager.getCwd() === cwd) addSessionName(names, session.sessionName); + } + return [...names]; + } + private async closeActive(sessionId: string): Promise { const active = this.active.get(sessionId); if (!active) return; @@ -751,6 +766,11 @@ function clientSessionFromArchivedRecord(record: ArchivedSessionRecord, fallback }; } +function addSessionName(names: Set, name: string | undefined): void { + const trimmed = name?.replace(/\s+/g, " ").trim(); + if (trimmed !== undefined && trimmed !== "") names.add(trimmed); +} + function compareArchivedRecords(a: ArchivedSessionRecord, b: ArchivedSessionRecord): number { return archivedTimestamp(b) - archivedTimestamp(a); } diff --git a/src/server/sessions/sessionCommandService.test.ts b/src/server/sessions/sessionCommandService.test.ts index 4ab1b24..168e22c 100644 --- a/src/server/sessions/sessionCommandService.test.ts +++ b/src/server/sessions/sessionCommandService.test.ts @@ -124,6 +124,49 @@ describe("SessionCommandService", () => { await expect(service.respond("s1", result.requestId, "newest")).resolves.toEqual({ type: "unsupported", message: "Command request expired" }); }); + it("names forked sessions from the source title with the next available counter", async () => { + const active = activeSession({ sessionName: "Build auth" }); + const forked = activeSession({ sessionId: "forked", sessionName: undefined }).runtime.session; + vi.mocked(active.runtime.fork).mockImplementationOnce(() => { + active.runtime.session = forked; + return Promise.resolve({ cancelled: false, selectedText: "newest message" }); + }); + const events = eventPublisher(); + const service = new SessionCommandService(() => getActive(active), vi.fn(), events, {}, { + listSessionNames: () => Promise.resolve(["Build auth", "Build auth — Fork 1"]), + }); + + const result = await service.run("s1", "/fork"); + if (result.type !== "select") throw new Error("Expected select result"); + await expect(service.respond("s1", result.requestId, "newest")).resolves.toMatchObject({ + type: "done", + message: "Session forked", + session: { id: "forked", name: "Build auth — Fork 2" }, + }); + expect(forked.setSessionName).toHaveBeenCalledWith("Build auth — Fork 2"); + expect(events.publish).toHaveBeenCalledWith("forked", { type: "session.name", sessionId: "forked", name: "Build auth — Fork 2" }); + }); + + it("names cloned sessions as copies of the source title", async () => { + const active = activeSession({ sessionName: "Build auth — Fork 1" }); + const cloned = activeSession({ sessionId: "copy", sessionName: undefined }).runtime.session; + vi.mocked(active.runtime.fork).mockImplementationOnce(() => { + active.runtime.session = cloned; + return Promise.resolve({ cancelled: false }); + }); + const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), {}, { + listSessionNames: () => Promise.resolve(["Build auth", "Build auth — Copy 1"]), + }); + + await expect(service.run("s1", "/clone")).resolves.toMatchObject({ + type: "done", + message: "Session cloned", + session: { id: "copy", name: "Build auth — Copy 2" }, + }); + expect(active.runtime.fork).toHaveBeenCalledWith("leaf-1", { position: "at" }); + expect(cloned.setSessionName).toHaveBeenCalledWith("Build auth — Copy 2"); + }); + it("rejects fork and clone while the session has active work", async () => { const active = activeSession({ isStreaming: true }); const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher()); diff --git a/src/server/sessions/sessionCommandService.ts b/src/server/sessions/sessionCommandService.ts index b756ab3..059a988 100644 --- a/src/server/sessions/sessionCommandService.ts +++ b/src/server/sessions/sessionCommandService.ts @@ -44,8 +44,15 @@ export type GetCommandActiveSession): void; } +export interface SessionCommandNaming { + listSessionNames?: (cwd: string) => Promise; +} + +type RelatedSessionKind = "fork" | "copy"; + interface PendingCommandSelect { sessionId: string; command: "fork"; @@ -62,6 +69,7 @@ export class SessionCommandService void; onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void; } = {}, + private readonly naming: SessionCommandNaming = {}, ) {} async run(sessionId: string, text: string): Promise { @@ -94,14 +102,17 @@ export class SessionCommandService, name: string): ClientCommandResult { if (name === "") return { type: "unsupported", message: "Usage: /name " }; active.runtime.session.setSessionName(name); + this.publishSessionName(active.runtime.session); return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) }; } @@ -129,8 +140,10 @@ export class SessionCommandService, kind: RelatedSessionKind): Promise { + const sourceTitle = relatedSessionSourceTitle(active.runtime.session); + const sourceName = normalizedName(active.runtime.session.sessionName); + let existingNames: readonly string[]; + try { + existingNames = await this.naming.listSessionNames?.(active.runtime.cwd) ?? []; + } catch { + existingNames = []; + } + return uniqueRelatedSessionName(sourceTitle, kind, sourceName === undefined ? existingNames : [...existingNames, sourceName]); + } + + private tryNameRelatedSession(session: TSession, name: string): void { + try { + session.setSessionName(name); + this.publishSessionName(session); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.events.publish(session.sessionId, { type: "command.output", level: "error", message: `Session created, but naming failed: ${message}` }); + } + } + + private publishSessionName(session: TSession): void { + const event = session.sessionName === undefined + ? { type: "session.name", sessionId: session.sessionId } as const + : { type: "session.name", sessionId: session.sessionId, name: session.sessionName } as const; + this.events.publish(session.sessionId, event); + this.events.publishGlobal?.(event); + } + private isRuntimeCommand(session: TSession, name: string): boolean { return session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === name) || session.promptTemplates.some((template) => template.name === name) @@ -171,6 +214,54 @@ function clientSessionFromRuntime(runtime: CommandRuntime): ClientSession { }; } +function relatedSessionSourceTitle(session: CommandSession): string { + const name = normalizedName(session.sessionName); + if (name !== undefined) return name; + for (const message of session.messages) { + const text = normalizedName(extractUserMessageText(message)); + if (text !== undefined) return truncate(text, 80); + } + return "Untitled session"; +} + +function uniqueRelatedSessionName(sourceTitle: string, kind: RelatedSessionKind, existingNames: readonly string[]): string { + const baseName = stripRelatedSessionSuffix(sourceTitle) || "Untitled session"; + const label = kind === "fork" ? "Fork" : "Copy"; + const usedNames = new Set(existingNames.map(normalizedName).filter(isDefined)); + for (let counter = 1; ; counter += 1) { + const candidate = `${baseName} — ${label} ${String(counter)}`; + if (!usedNames.has(candidate)) return candidate; + } +} + +function stripRelatedSessionSuffix(name: string): string { + return name.replace(/\s+(?:—|-)\s+(?:Fork|Copy|Clone)\s+\d+$/u, "").trim(); +} + +function extractUserMessageText(message: unknown): string | undefined { + if (!isRecord(message) || message["role"] !== "user") return undefined; + const content = message["content"]; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return undefined; + return content.map((part) => { + if (!isRecord(part) || part["type"] !== "text") return ""; + return typeof part["text"] === "string" ? part["text"] : ""; + }).join(""); +} + +function normalizedName(name: string | undefined): string | undefined { + const trimmed = name?.replace(/\s+/g, " ").trim(); + return trimmed === undefined || trimmed === "" ? undefined : trimmed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isDefined(value: T | undefined): value is T { + return value !== undefined; +} + function sessionHasActiveWork(session: CommandSession): boolean { return session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0; }