fix: show pending session starts safely

This commit is contained in:
Federico Jaramillo Martinez
2026-07-02 14:16:17 +02:00
parent b61a9c0c54
commit 3cb81b1bcf
7 changed files with 180 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Improve session-start feedback so concurrent new sessions stay visible without disrupting session-list navigation.
+5
View File
@@ -20,6 +20,8 @@ export interface AppState {
isReceivingPartialStream: boolean;
/** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */
sendingPrompts: Record<string, true>;
/** Client-initiated session creation requests waiting for the server. */
startingSessionCount: number;
isLoadingProjects: boolean;
isLoadingWorkspaces: boolean;
selectedProject: Project | undefined;
@@ -72,6 +74,7 @@ export type AuthDialogState =
export type WorkspaceScopedStateReset = Pick<AppState,
| "sessions"
| "startingSessionCount"
| "fileTree"
| "expandedDirs"
| "selectedFilePath"
@@ -89,6 +92,7 @@ export type WorkspaceScopedStateReset = Pick<AppState,
export function resetWorkspaceScopedState(): WorkspaceScopedStateReset {
return {
sessions: [],
startingSessionCount: 0,
fileTree: [],
expandedDirs: {},
selectedFilePath: undefined,
@@ -121,6 +125,7 @@ export function initialAppState(): AppState {
isLoadingEarlierMessages: false,
isReceivingPartialStream: false,
sendingPrompts: {},
startingSessionCount: 0,
isLoadingProjects: false,
isLoadingWorkspaces: false,
selectedProject: undefined,
+1
View File
@@ -1119,6 +1119,7 @@ export class PiWebApp extends LitElement {
.sessionActivities=${this.state.sessionActivities}
.sendingPrompts=${this.state.sendingPrompts}
.selectedSession=${this.state.selectedSession}
.startingSessionCount=${this.state.startingSessionCount}
.canStartSession=${!!this.state.selectedWorkspace}
.canDeleteArchivedSessions=${this.canDeleteArchivedSessions()}
.canReloadSessions=${this.canReloadSessions()}
+26 -2
View File
@@ -29,6 +29,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sending: Record<string, true> = {};
@property({ attribute: false }) selected?: SessionInfo;
@property({ type: Number }) startingCount = 0;
@property({ type: Boolean }) canStart = false;
@property({ type: Boolean }) canDeleteArchived = false;
@property({ type: Boolean }) canReload = false;
@@ -109,6 +110,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
${this.collapsed ? null : html`
<div class="list-body">
${this.renderCurrentSelectionToolbar(currentSelectableSessions)}
${this.startingCount > 0 ? this.renderStartingSession() : null}
${currentRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0, "current"))}
${archivedRows.length > 0 ? html`
${this.renderArchivedHeading(archivedRows.map((row) => row.session))}
@@ -130,7 +132,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
Sessions
${this.renderCurrentSelectionButton(currentSessions)}
${this.renderCleanupButton()}
<button ?disabled=${!this.canStart} @click=${() => this.onStart?.()}>+</button>
${this.renderStartButton()}
</h2>
`;
}
@@ -142,7 +144,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
${this.renderCurrentSelectionButton(currentSessions)}
<small class="section-count">${sessionCount}</small>
${this.renderCleanupButton()}
<button ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>
${this.renderStartButton()}
</h2>
`;
}
@@ -157,6 +159,23 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
return html`<button class="cleanup-entry" title=${this.canCleanup ? "Preview session cleanup" : this.cleanupUnavailableMessage} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onCleanup?.(); }}>Clean up</button>`;
}
private renderStartButton() {
const title = this.startingCount > 0 ? "Start another session" : "Start a new session";
return html`<button class="start-session-button" title=${title} aria-label=${title} ?disabled=${!this.canStart} @click=${(event: MouseEvent) => { event.stopPropagation(); this.onStart?.(); }}>+</button>`;
}
private renderStartingSession() {
const plural = this.startingCount !== 1;
return html`
<div class="pending-session-row starting-session" role="status" aria-live="polite">
<div class="action-main">
<span class="action-name"><span class="activity-indicator sending" aria-hidden="true"></span>${plural ? `Starting ${String(this.startingCount)} sessions…` : "Starting session…"}</span>
<small>Waiting for ${plural ? "new sessions" : "the new session"} to be created</small>
</div>
</div>
`;
}
private renderArchivedHeading(archivedSessions: SessionInfo[]) {
const active = this.selectionScopes.has("archived");
return html`
@@ -381,6 +400,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
h2 { min-height: 30px; }
h2 > .section-count { flex: 0 0 auto; display: inline; color: var(--pi-muted); font-size: inherit; }
.bulk-select-entry { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; font-size: 13px; line-height: 1; text-transform: none; }
.start-session-button { box-sizing: border-box; flex: 0 0 auto; display: inline-grid; place-items: center; min-width: 30px; height: 30px; padding: 0 9px; }
.cleanup-entry { flex: 0 0 auto; padding: 5px 7px; font-size: 12px; text-transform: none; }
.bulk-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
.bulk-row button { padding: 5px 7px; font-size: 12px; }
@@ -391,6 +411,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
button.danger, .action-menu-panel button.danger { color: var(--pi-danger); }
button.danger:hover, .action-menu-panel button.danger:hover { background: color-mix(in srgb, var(--pi-danger) 14%, transparent); }
.action-row.bulk-selected .action-main { border-color: var(--pi-accent); box-shadow: inset 3px 0 0 var(--pi-accent); }
.pending-session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); margin: 6px 0; cursor: default; }
.pending-session-row.starting-session .action-main { border-radius: 8px; border-style: dashed; color: var(--pi-muted); }
.pending-session-row.starting-session .action-name { display: flex; align-items: center; gap: 6px; max-height: none; -webkit-line-clamp: 1; }
.pending-session-row.starting-session .activity-indicator { flex: 0 0 auto; margin: 0; }
.action-main.selecting { padding-left: calc(32px + var(--depth, 0) * 16px); }
.session-checkbox { position: absolute; top: 9px; left: calc(8px + var(--depth, 0) * 16px); z-index: 2; margin: 0; }
`];
@@ -39,6 +39,7 @@ export class AppNavigationPanel extends LitElement {
@property({ type: Boolean }) projectsCollapsed = false;
@property({ type: Boolean }) workspacesCollapsed = false;
@property({ type: Boolean }) sessionsCollapsed = false;
@property({ type: Number }) startingSessionCount = 0;
@property({ type: Boolean }) canStartSession = false;
@property({ type: Boolean }) canDeleteArchivedSessions = false;
@property({ type: Boolean }) canReloadSessions = false;
@@ -159,6 +160,7 @@ export class AppNavigationPanel extends LitElement {
.activities=${this.sessionActivities}
.sending=${this.sendingPrompts}
.selected=${this.selectedSession}
.startingCount=${this.startingSessionCount}
.canStart=${this.canStartSession}
.canDeleteArchived=${this.canDeleteArchivedSessions}
.canReload=${this.canReloadSessions}
@@ -103,6 +103,23 @@ const replacementSession: SessionInfo = {
const emptyPage: MessagePage = { messages: [], start: 0, total: 0 };
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolveDeferred: ((value: T) => void) | undefined;
let rejectDeferred: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
}
function status(sessionId: string): SessionStatus {
return {
sessionId,
@@ -334,6 +351,103 @@ describe("SessionController", () => {
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
});
it("tracks multiple pending session starts without blocking another start", async () => {
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
const startResolvers: ((session: SessionInfo) => void)[] = [];
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => new Promise<SessionInfo>((resolve) => { startResolvers.push(resolve); }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const firstStart = controller.startSession();
const secondStart = controller.startSession();
expect(startResolvers).toHaveLength(2);
expect(state.startingSessionCount).toBe(2);
expect(state.sessions).toEqual([]);
startResolvers[0]?.(firstStarted);
await firstStart;
expect(state.startingSessionCount).toBe(1);
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-1"]);
startResolvers[1]?.(secondStarted);
await secondStart;
expect(state.startingSessionCount).toBe(0);
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]);
expect(state.selectedSession?.id).toBe("started-session-2");
});
it("removes a resolved session start from the pending count when inserting its row", async () => {
const firstStarted: SessionInfo = { ...oldSession, id: "started-session-1", path: "/tmp/started-session-1.jsonl" };
const secondStarted: SessionInfo = { ...oldSession, id: "started-session-2", path: "/tmp/started-session-2.jsonl" };
const startResolvers: ((session: SessionInfo) => void)[] = [];
const messageRequests = new Map<string, Deferred<MessagePage>>();
const statusRequests = new Map<string, Deferred<SessionStatus>>();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const api: typeof defaultApi = {
...defaultApi,
startSession: () => new Promise<SessionInfo>((resolve) => { startResolvers.push(resolve); }),
messages: (session) => {
const request = deferred<MessagePage>();
messageRequests.set(sessionLookupId(session), request);
return request.promise;
},
status: (session) => {
const request = deferred<SessionStatus>();
statusRequests.set(sessionLookupId(session), request);
return request.promise;
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const firstStart = controller.startSession();
const secondStart = controller.startSession();
expect(startResolvers).toHaveLength(2);
expect(state.startingSessionCount).toBe(2);
startResolvers[0]?.(firstStarted);
await Promise.resolve();
await Promise.resolve();
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-1"]);
expect(state.startingSessionCount).toBe(1);
messageRequests.get(firstStarted.id)?.resolve(emptyPage);
statusRequests.get(firstStarted.id)?.resolve(status(firstStarted.id));
await firstStart;
startResolvers[1]?.(secondStarted);
await Promise.resolve();
await Promise.resolve();
expect(state.sessions.map((session) => session.id)).toEqual(["started-session-2", "started-session-1"]);
expect(state.startingSessionCount).toBe(0);
messageRequests.get(secondStarted.id)?.resolve(emptyPage);
statusRequests.get(secondStarted.id)?.resolve(status(secondStarted.id));
await secondStart;
});
it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => {
let resolvePrompt: (() => void) | undefined;
let promptArgs: { attachments?: PromptAttachment[] } | undefined;
@@ -98,18 +98,37 @@ export class SessionController {
async startSession() {
const workspace = this.getState().selectedWorkspace;
if (!workspace) return;
const machineId = selectedMachineId(this.getState());
const isCurrentWorkspace = () => selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id;
this.setState({ startingSessionCount: this.getState().startingSessionCount + 1, error: "" });
let shouldDecrementStartingCount = true;
try {
const machineId = selectedMachineId(this.getState());
const session = await this.api.startSession(workspace.path, machineId);
rememberCachedNewSession(session, machineId);
const cachedSession = markCachedNewSessionInfo(session, machineId);
if (!isCurrentWorkspace()) return;
const state = this.getState();
// Drop any entry the session.created broadcast may have inserted for this
// same session before the HTTP response resolved, so the cached marker
// (and its delete action) wins instead of leaving a duplicate badge.
this.setState({ sessions: [cachedSession, ...this.getState().sessions.filter((candidate) => candidate.id !== cachedSession.id)] });
// (and its delete action) wins instead of leaving a duplicate badge. The
// pending count for this completed request is consumed in the same patch
// so the list never renders both the real session and its placeholder.
this.setState({
sessions: [cachedSession, ...state.sessions.filter((candidate) => candidate.id !== cachedSession.id)],
startingSessionCount: decrementStartingSessionCount(state.startingSessionCount),
});
shouldDecrementStartingCount = false;
await this.selectSession(cachedSession);
} catch (error) {
this.setState({ error: String(error) });
if (!isCurrentWorkspace()) return;
if (!shouldDecrementStartingCount) {
this.setState({ error: String(error) });
return;
}
this.setState({ error: String(error), startingSessionCount: decrementStartingSessionCount(this.getState().startingSessionCount) });
shouldDecrementStartingCount = false;
} finally {
if (shouldDecrementStartingCount && isCurrentWorkspace()) this.setState({ startingSessionCount: decrementStartingSessionCount(this.getState().startingSessionCount) });
}
}
@@ -854,6 +873,10 @@ function omitSessionActivity(activities: Record<string, SessionActivity>, sessio
return omitKey(activities, sessionId);
}
function decrementStartingSessionCount(count: number): number {
return Math.max(0, count - 1);
}
function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key));
}