Archived
feat: name forked and cloned sessions
This commit is contained in:
@@ -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.
|
||||
@@ -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<string[]> {
|
||||
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
|
||||
const names = new Set<string>();
|
||||
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<void> {
|
||||
const active = this.active.get(sessionId);
|
||||
if (!active) return;
|
||||
@@ -751,6 +766,11 @@ function clientSessionFromArchivedRecord(record: ArchivedSessionRecord, fallback
|
||||
};
|
||||
}
|
||||
|
||||
function addSessionName(names: Set<string>, 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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -44,8 +44,15 @@ export type GetCommandActiveSession<TSession extends CommandSession = CommandSes
|
||||
|
||||
export interface CommandEventPublisher {
|
||||
publish(sessionId: string, event: SessionUiEvent): void;
|
||||
publishGlobal?(event: Extract<SessionUiEvent, { type: "session.name" }>): void;
|
||||
}
|
||||
|
||||
export interface SessionCommandNaming {
|
||||
listSessionNames?: (cwd: string) => Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
type RelatedSessionKind = "fork" | "copy";
|
||||
|
||||
interface PendingCommandSelect {
|
||||
sessionId: string;
|
||||
command: "fork";
|
||||
@@ -62,6 +69,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
onCompactionStart?: (session: TSession) => void;
|
||||
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
|
||||
} = {},
|
||||
private readonly naming: SessionCommandNaming = {},
|
||||
) {}
|
||||
|
||||
async run(sessionId: string, text: string): Promise<ClientCommandResult> {
|
||||
@@ -94,14 +102,17 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
|
||||
const active = await this.getActive(sessionId);
|
||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("fork");
|
||||
const relatedName = await this.nextRelatedSessionName(active, "fork");
|
||||
const result = await active.runtime.fork(value);
|
||||
if (result.cancelled) return { type: "done", message: "Fork cancelled" };
|
||||
this.tryNameRelatedSession(active.runtime.session, relatedName);
|
||||
return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime), ...promptDraft(result.selectedText) };
|
||||
}
|
||||
|
||||
private nameSession(active: CommandActiveSession<TSession>, name: string): ClientCommandResult {
|
||||
if (name === "") return { type: "unsupported", message: "Usage: /name <session 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<TSession extends CommandSession = CommandSess
|
||||
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
|
||||
const leafId = active.runtime.session.sessionManager.getLeafId();
|
||||
if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" };
|
||||
const relatedName = await this.nextRelatedSessionName(active, "copy");
|
||||
const result = await active.runtime.fork(leafId, { position: "at" });
|
||||
if (result.cancelled) return { type: "done", message: "Clone cancelled" };
|
||||
this.tryNameRelatedSession(active.runtime.session, relatedName);
|
||||
return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) };
|
||||
}
|
||||
|
||||
@@ -148,6 +161,36 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
|
||||
};
|
||||
}
|
||||
|
||||
private async nextRelatedSessionName(active: CommandActiveSession<TSession>, kind: RelatedSessionKind): Promise<string> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isDefined<T>(value: T | undefined): value is T {
|
||||
return value !== undefined;
|
||||
}
|
||||
|
||||
function sessionHasActiveWork(session: CommandSession): boolean {
|
||||
return session.isStreaming || session.isBashRunning || session.isCompacting || session.pendingMessageCount > 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user