feat: preserve browser-cached new sessions

This commit is contained in:
Federico Jaramillo Martinez
2026-05-15 09:39:17 +02:00
parent c5bc855112
commit aab9ffb8e2
10 changed files with 460 additions and 38 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Preserve newly started empty sessions and their prompt drafts across browser reloads until the user deletes them.
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { SessionInfo } from "./api";
import { forgetCachedNewSession, isCachedNewSessionInfo, loadCachedNewSessions, mergeCachedNewSessions, rememberCachedNewSession } from "./cachedNewSessions";
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number {
return this.values.size;
}
clear(): void {
this.values.clear();
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
key(index: number): string | null {
return Array.from(this.values.keys())[index] ?? null;
}
removeItem(key: string): void {
this.values.delete(key);
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
}
const baseSession: SessionInfo = {
id: "session-1",
path: "/tmp/session-1.jsonl",
cwd: "/repo",
created: "2026-05-15T00:00:00.000Z",
modified: "2026-05-15T00:00:00.000Z",
messageCount: 0,
firstMessage: "",
};
describe("cached new sessions", () => {
it("stores and reloads new sessions with a browser-cache marker", () => {
const storage = new MemoryStorage();
rememberCachedNewSession(baseSession, storage);
const cached = loadCachedNewSessions(storage);
expect(cached).toHaveLength(1);
expect(cached[0]?.id).toBe("session-1");
expect(isCachedNewSessionInfo(cached[0])).toBe(true);
});
it("merges cached sessions for the selected cwd without duplicating server sessions", () => {
const storage = new MemoryStorage();
rememberCachedNewSession(baseSession, storage);
rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, storage);
expect(mergeCachedNewSessions("/repo", [], storage).map((session) => session.id)).toEqual(["session-1"]);
expect(mergeCachedNewSessions("/repo", [baseSession], storage).map((session) => session.id)).toEqual(["session-1"]);
expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], storage)[0])).toBe(false);
expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]);
});
it("forgets cached sessions", () => {
const storage = new MemoryStorage();
rememberCachedNewSession(baseSession, storage);
forgetCachedNewSession("session-1", storage);
expect(loadCachedNewSessions(storage)).toEqual([]);
});
});
+129
View File
@@ -0,0 +1,129 @@
import type { SessionInfo } from "./api";
const storageKey = "pi-web:cached-new-sessions:v1";
const markerProperty = "browserCachedNew";
export type CachedNewSessionInfo = SessionInfo & { browserCachedNew: true };
function browserStorage(): Storage | undefined {
try {
return typeof localStorage === "undefined" ? undefined : localStorage;
} catch {
return undefined;
}
}
export function rememberCachedNewSession(session: SessionInfo, storage = browserStorage()): void {
if (session.messageCount !== 0 || session.archived === true) return;
const sessions = loadCachedNewSessions(storage).filter((candidate) => candidate.id !== session.id);
saveCachedNewSessions([markCachedNewSessionInfo(session), ...sessions], storage);
}
export function markCachedNewSessionInfo(session: SessionInfo): CachedNewSessionInfo {
return { ...session, browserCachedNew: true };
}
export function forgetCachedNewSession(sessionId: string, storage = browserStorage()): void {
const sessions = loadCachedNewSessions(storage).filter((session) => session.id !== sessionId);
saveCachedNewSessions(sessions, storage);
}
export function mergeCachedNewSessions(cwd: string, sessions: SessionInfo[], storage = browserStorage()): SessionInfo[] {
const sessionIds = new Set(sessions.map((session) => session.id));
const cachedSessions = loadCachedNewSessions(storage);
const retainedCachedSessions = cachedSessions.filter((session) => !sessionIds.has(session.id));
if (retainedCachedSessions.length !== cachedSessions.length) saveCachedNewSessions(retainedCachedSessions, storage);
const cached = retainedCachedSessions.filter((session) => session.cwd === cwd);
return [...cached, ...sessions];
}
export function isCachedNewSessionInfo(session: SessionInfo | undefined): session is CachedNewSessionInfo {
if (session === undefined) return false;
return hasCachedNewMarker(session) && session.browserCachedNew === true;
}
export function stripCachedNewSessionMarker(session: SessionInfo): SessionInfo {
return {
id: session.id,
path: session.path,
cwd: session.cwd,
...(session.name === undefined ? {} : { name: session.name }),
created: session.created,
modified: session.modified,
messageCount: session.messageCount,
firstMessage: session.firstMessage,
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
...(session.archived === true ? { archived: true } : {}),
...(session.archivedAt === undefined ? {} : { archivedAt: session.archivedAt }),
};
}
export function loadCachedNewSessions(storage = browserStorage()): CachedNewSessionInfo[] {
try {
const raw = storage?.getItem(storageKey);
if (raw === undefined || raw === null || raw === "") return [];
const value: unknown = JSON.parse(raw);
if (!Array.isArray(value)) return [];
return value.flatMap((candidate) => parseCachedSession(candidate));
} catch {
return [];
}
}
function saveCachedNewSessions(sessions: CachedNewSessionInfo[], storage = browserStorage()): void {
try {
if (sessions.length === 0) storage?.removeItem(storageKey);
else storage?.setItem(storageKey, JSON.stringify(sessions));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
function parseCachedSession(value: unknown): CachedNewSessionInfo[] {
if (!isRecord(value)) return [];
const id = stringField(value, "id");
const path = stringField(value, "path");
const cwd = stringField(value, "cwd");
const created = stringField(value, "created");
const modified = stringField(value, "modified");
const firstMessage = stringField(value, "firstMessage");
const messageCount = numberField(value, "messageCount");
if (id === undefined || path === undefined || cwd === undefined || created === undefined || modified === undefined || firstMessage === undefined || messageCount !== 0) return [];
const name = optionalStringField(value, "name");
const parentSessionPath = optionalStringField(value, "parentSessionPath");
return [{
id,
path,
cwd,
...(name === undefined ? {} : { name }),
created,
modified,
messageCount,
firstMessage,
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
browserCachedNew: true,
}];
}
function hasCachedNewMarker(session: SessionInfo): session is SessionInfo & { browserCachedNew: unknown } {
return markerProperty in session;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function stringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function optionalStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return value === undefined || typeof value !== "string" ? undefined : value;
}
function numberField(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return typeof value === "number" ? value : undefined;
}
+1 -1
View File
@@ -268,7 +268,7 @@ export class PiWebApp extends LitElement {
</header>
<project-list .projects=${this.state.projects} .selected=${this.state.selectedProject} .onSelect=${(project: Project) => this.withChatScrollTransition(() => this.workspaces.selectProject(project))} .onClose=${(project: Project) => this.projects.closeProject(project.id)}></project-list>
<workspace-list .workspaces=${this.state.workspaces} .selected=${this.state.selectedWorkspace} .workspaceLabelItems=${(workspace: Workspace) => this.plugins.getWorkspaceLabelItems(this.state, workspace)} .onSelect=${(workspace: Workspace) => this.withChatScrollTransition(() => this.workspaces.selectWorkspace(workspace))}></workspace-list>
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .onStart=${() => openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}></session-list>
<session-list .sessions=${this.state.sessions} .statuses=${this.state.sessionStatuses} .activities=${this.state.sessionActivities} .selected=${this.state.selectedSession} .canStart=${!!this.state.selectedWorkspace} .onStart=${() => openChatAfter(() => this.sessions.startSession())} .onSelect=${(session: SessionInfo) => openChatAfter(() => this.sessions.selectSession(session))} .onArchive=${(session: SessionInfo) => this.sessions.archiveSession(session)} .onRestore=${(session: SessionInfo) => openChatAfter(() => this.sessions.restoreSession(session))} .onDelete=${(session: SessionInfo) => this.sessions.deleteCachedNewSession(session)} .onDetachParent=${(session: SessionInfo) => this.sessions.detachParent(session)}></session-list>
`;
}
+1 -30
View File
@@ -7,6 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
import { inputModeForDraft } from "../inputModes";
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { promptEditorStyles, type CompletionItem } from "./shared";
import "./AutocompleteMenu";
@@ -298,33 +299,3 @@ function emptyFileSuggestions(): FileSuggestion[] {
return [];
}
const draftStoragePrefix = "pi-web:prompt-draft:";
function draftStorageKey(sessionId: string): string {
return `${draftStoragePrefix}${sessionId}`;
}
function loadDraft(sessionId: string): string {
try {
return localStorage.getItem(draftStorageKey(sessionId)) ?? "";
} catch {
return "";
}
}
function saveDraft(sessionId: string, draft: string): void {
try {
if (draft) localStorage.setItem(draftStorageKey(sessionId), draft);
else localStorage.removeItem(draftStorageKey(sessionId));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
function clearDraft(sessionId: string): void {
try {
localStorage.removeItem(draftStorageKey(sessionId));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
+8 -3
View File
@@ -1,6 +1,7 @@
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { SessionActivity, SessionInfo, SessionStatus } from "../api";
import { isCachedNewSessionInfo } from "../cachedNewSessions";
import { activateSelectableRow, activateSelectableRowFromKeyboard } from "./selectableRow";
import { listStyles } from "./shared";
@@ -33,6 +34,7 @@ export class SessionList extends LitElement {
};
@property({ attribute: false }) onArchive?: (session: SessionInfo) => void;
@property({ attribute: false }) onRestore?: (session: SessionInfo) => void;
@property({ attribute: false }) onDelete?: (session: SessionInfo) => void;
@property({ attribute: false }) onDetachParent?: (session: SessionInfo) => void;
override connectedCallback(): void {
@@ -92,9 +94,11 @@ export class SessionList extends LitElement {
${this.openMenuSessionId === session.id ? html`
<div class="action-menu-panel" style=${this.menuStyle}>
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${session.archived === true
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
${isCachedNewSessionInfo(session)
? html`<button title="Delete browser-cached new session" @click=${() => { this.openMenuSessionId = undefined; this.onDelete?.(session); }}>Delete</button>`
: session.archived === true
? html`<button title="Restore session" @click=${() => { this.openMenuSessionId = undefined; this.onRestore?.(session); }}>Restore</button>`
: html`<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>`}
</div>
` : null}
</div>
@@ -124,6 +128,7 @@ export class SessionList extends LitElement {
}
private renderStatus(session: SessionInfo) {
if (isCachedNewSessionInfo(session)) return "new · ";
if (session.archived === true) return "read-only · ";
const status = this.statuses[session.id];
const activity = this.activities[session.id];
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from "vitest";
import { api as defaultApi, type MessagePage, type SessionInfo, type SessionStatus, type Workspace } from "../api";
import { loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
import { initialAppState, type AppState } from "../appState";
import { loadDraft, saveDraft } from "../promptDraftStorage";
import { SessionController, type SessionEventSocket } from "./sessionController";
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number {
return this.values.size;
}
clear(): void {
this.values.clear();
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
key(index: number): string | null {
return Array.from(this.values.keys())[index] ?? null;
}
removeItem(key: string): void {
this.values.delete(key);
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
}
class FakeSocket implements SessionEventSocket {
readonly connectedSessionIds: string[] = [];
connect(sessionId: string): void {
this.connectedSessionIds.push(sessionId);
}
setHandler(): void {
// Test socket does not emit events.
}
close(): void {
// No-op.
}
}
const workspace: Workspace = {
id: "workspace-1",
projectId: "project-1",
path: "/repo",
label: "repo",
isMain: true,
isGitRepo: true,
isGitWorktree: false,
};
const oldSession: SessionInfo = {
id: "old-session",
path: "/tmp/old-session.jsonl",
cwd: "/repo",
created: "2026-05-15T00:00:00.000Z",
modified: "2026-05-15T00:00:00.000Z",
messageCount: 0,
firstMessage: "",
};
const replacementSession: SessionInfo = {
...oldSession,
id: "new-session",
path: "/tmp/new-session.jsonl",
};
const emptyPage: MessagePage = { messages: [], start: 0, total: 0 };
function status(sessionId: string): SessionStatus {
return {
sessionId,
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
describe("SessionController", () => {
afterEach(() => {
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
});
it("recreates missing browser-cached new sessions and moves their draft", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
rememberCachedNewSession(oldSession);
saveDraft(oldSession.id, "draft text");
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [markCachedNewSessionInfo(oldSession)] };
const urlUpdates: ({ replace?: boolean | undefined } | undefined)[] = [];
const socket = new FakeSocket();
const api: typeof defaultApi = {
...defaultApi,
startSession: () => Promise.resolve(replacementSession),
messages: (sessionId) => {
if (sessionId === oldSession.id) return Promise.reject(new Error("Session not found"));
return Promise.resolve(emptyPage);
},
status: (sessionId) => Promise.resolve(status(sessionId)),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
(options) => { urlUpdates.push(options); },
undefined,
{ api, socket },
);
await controller.selectSession(markCachedNewSessionInfo(oldSession), { updateUrl: false });
expect(state.selectedSession?.id).toBe(replacementSession.id);
expect(state.sessions.map((session) => session.id)).toEqual([replacementSession.id]);
expect(socket.connectedSessionIds).toEqual([oldSession.id, replacementSession.id]);
expect(loadDraft(oldSession.id)).toBe("");
expect(loadDraft(replacementSession.id)).toBe("draft text");
expect(loadCachedNewSessions().map((session) => session.id)).toEqual([replacementSession.id]);
expect(urlUpdates).toEqual([{ replace: true }]);
});
});
@@ -1,5 +1,7 @@
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus, type ThinkingLevel } from "../api";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages";
import { clearDraft, moveDraft } from "../promptDraftStorage";
import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
@@ -64,8 +66,10 @@ export class SessionController {
if (!workspace) return;
try {
const session = await this.api.startSession(workspace.path);
this.setState({ sessions: [session, ...this.getState().sessions] });
await this.selectSession(session);
rememberCachedNewSession(session);
const cachedSession = markCachedNewSessionInfo(session);
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
await this.selectSession(cachedSession);
} catch (error) {
this.setState({ error: String(error) });
}
@@ -116,7 +120,12 @@ export class SessionController {
this.socket.setHandler((event) => { this.applyEvent(event); });
if (options?.updateUrl !== false) this.updateUrl();
} catch (error) {
if (seq === this.selectionSeq) this.setState({ error: String(error) });
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
if (isCachedNewSessionInfo(session) && isSessionNotFoundError(error)) {
await this.recreateCachedNewSession(session, options);
return;
}
this.setState({ error: String(error) });
}
}
@@ -145,6 +154,7 @@ export class SessionController {
if (!session || session.archived === true) return;
try {
await this.api.prompt(session.id, text, streamingBehavior);
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ error: String(error) });
}
@@ -156,6 +166,7 @@ export class SessionController {
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
await this.api.shell(session.id, text);
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
}
@@ -167,6 +178,7 @@ export class SessionController {
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
this.applyCommandResult(await this.api.runCommand(session.id, text));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
}
@@ -189,6 +201,10 @@ export class SessionController {
async archiveSession(session = this.getState().selectedSession) {
if (!session) return;
if (isCachedNewSessionInfo(session)) {
await this.deleteCachedNewSession(session);
return;
}
try {
await this.api.archive(session.id);
const state = this.getState();
@@ -206,6 +222,24 @@ export class SessionController {
}
}
async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return;
void this.api.stop(session.id).catch(() => {
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
});
forgetCachedNewSession(session.id);
clearDraft(session.id);
const sessions = this.getState().sessions.filter((candidate) => candidate.id !== session.id);
this.setState({ sessions });
if (this.getState().selectedSession?.id !== session.id) return;
const next = sessions.find((candidate) => candidate.archived !== true) ?? sessions[0];
if (next !== undefined) await this.selectSession(next);
else {
this.clearActiveSession();
this.updateUrl();
}
}
async restoreSession(session = this.getState().selectedSession) {
if (!session) return;
try {
@@ -332,6 +366,26 @@ export class SessionController {
});
}
private async recreateCachedNewSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }): Promise<void> {
try {
const replacement = await this.api.startSession(session.cwd);
rememberCachedNewSession(replacement);
moveDraft(session.id, replacement.id);
forgetCachedNewSession(session.id);
const cachedReplacement = markCachedNewSessionInfo(replacement);
this.setState({ sessions: [cachedReplacement, ...this.getState().sessions.filter((candidate) => candidate.id !== session.id)], error: "" });
await this.selectSession(cachedReplacement, { updateUrl: false });
this.updateUrl(options?.updateUrl === false ? { replace: true } : undefined);
} catch (error) {
this.setState({ error: String(error) });
}
}
private markCachedNewSessionPersisted(session: SessionInfo): void {
if (!isCachedNewSessionInfo(session)) return;
this.replaceSession(stripCachedNewSessionMarker(session));
}
private applyCommandResult(result: CommandResult) {
if (result.type === "select") {
this.setState({ commandDialog: result });
@@ -456,3 +510,7 @@ function isHighFrequencyTranscriptEvent(event: SessionUiEvent): boolean {
return event.type === "assistant.delta" || event.type === "assistant.thinking.delta" || event.type === "shell.chunk";
}
function isSessionNotFoundError(error: unknown): boolean {
return error instanceof Error && error.message.toLowerCase().includes("session not found");
}
@@ -1,4 +1,5 @@
import { api, type Project, type Workspace } from "../api";
import { mergeCachedNewSessions } from "../cachedNewSessions";
import type { GetState, RouteTarget, SetState, UpdateUrl } from "./types";
import type { SessionController } from "./sessionController";
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
@@ -41,7 +42,7 @@ export class WorkspaceController {
this.sessions.clearActiveSession();
this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" });
try {
const sessions = await api.sessions(workspace.path);
const sessions = mergeCachedNewSessions(workspace.path, await api.sessions(workspace.path));
this.setState({ sessions });
const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId);
if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl });
+45
View File
@@ -0,0 +1,45 @@
const draftStoragePrefix = "pi-web:prompt-draft:";
function draftStorageKey(sessionId: string): string {
return `${draftStoragePrefix}${sessionId}`;
}
function browserStorage(): Storage | undefined {
try {
return typeof localStorage === "undefined" ? undefined : localStorage;
} catch {
return undefined;
}
}
export function loadDraft(sessionId: string, storage = browserStorage()): string {
try {
return storage?.getItem(draftStorageKey(sessionId)) ?? "";
} catch {
return "";
}
}
export function saveDraft(sessionId: string, draft: string, storage = browserStorage()): void {
try {
if (draft) storage?.setItem(draftStorageKey(sessionId), draft);
else storage?.removeItem(draftStorageKey(sessionId));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
export function clearDraft(sessionId: string, storage = browserStorage()): void {
try {
storage?.removeItem(draftStorageKey(sessionId));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
export function moveDraft(fromSessionId: string, toSessionId: string, storage = browserStorage()): void {
const draft = loadDraft(fromSessionId, storage);
if (draft === "") return;
saveDraft(toSessionId, draft, storage);
clearDraft(fromSessionId, storage);
}