fix: respect Pi session directories by cwd

This commit is contained in:
Federico Jaramillo Martinez
2026-06-10 20:26:51 +02:00
parent 3330a5a7fa
commit 06052ea5ec
22 changed files with 583 additions and 252 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+29 -20
View File
@@ -1,4 +1,4 @@
import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -39,6 +39,15 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
function sessionUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}/${endpoint}`;
}
function sessionQueryUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
const query = new URLSearchParams({ cwd: session.cwd }).toString();
return `${sessionUrl(session, endpoint, machineId)}?${query}`;
}
export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
};
@@ -80,25 +89,25 @@ export const workspacesApi = {
export const sessionsApi = {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
messages: (sessionId: string, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(sessionId, options, machineId), parseMessagePage),
status: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/status`, parseSessionStatus),
models: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/models`, parseModelSelectionResponse),
setModel: (sessionId: string, provider: string, modelId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model`, parseSessionStatus, { method: "POST", body: JSON.stringify({ provider, modelId }) }),
cycleModel: (sessionId: string, direction: "forward" | "backward", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/model/cycle`, parseSessionStatus, { method: "POST", body: JSON.stringify({ direction }) }),
thinkingLevels: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-levels`, parseThinkingLevelsResponse),
setThinkingLevel: (sessionId: string, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level`, parseSessionStatus, { method: "POST", body: JSON.stringify({ level }) }),
cycleThinkingLevel: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/thinking-level/cycle`, parseSessionStatus, { method: "POST" }),
commands: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
shell: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
runCommand: (sessionId: string, text: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
respondToCommand: (sessionId: string, requestId: string, value: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
abort: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
stop: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
archive: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
archiveWithDescendants: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/archive-tree`, parseArchived, { method: "POST" }),
restore: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
detachParent: (sessionId: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/${sessionId}/detach-parent`, parseDetached, { method: "POST" }),
messages: (session: SessionRef, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
status: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
models: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
setModel: (session: SessionRef, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, provider, modelId }) }),
cycleModel: (session: SessionRef, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, direction }) }),
thinkingLevels: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
setThinkingLevel: (session: SessionRef, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, level }) }),
cycleThinkingLevel: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
commands: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
prompt: (session: SessionRef, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { cwd: session.cwd, text } : { cwd: session.cwd, text, streamingBehavior }) }),
shell: (session: SessionRef, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: JSON.stringify({ cwd: session.cwd, text }) }),
runCommand: (session: SessionRef, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: JSON.stringify({ cwd: session.cwd, text }) }),
respondToCommand: (session: SessionRef, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: JSON.stringify({ cwd: session.cwd, requestId, value }) }),
abort: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
stop: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
archive: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
archiveWithDescendants: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
restore: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
detachParent: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
const params = new URLSearchParams();
if (options?.mode !== undefined) params.set("mode", options.mode);
@@ -15,6 +15,7 @@ const workspace: Workspace = {
isGitRepo: true,
isGitWorktree: true,
};
const session = { id: "s 1", cwd: workspace.path };
afterEach(() => {
vi.unstubAllGlobals();
@@ -40,25 +41,25 @@ describe("federated route contract", () => {
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
ignoreParseFailure(sessionsApi.startSession("/repo", machineId)),
ignoreParseFailure(sessionsApi.messages("s 1", { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status("s 1", machineId)),
ignoreParseFailure(sessionsApi.models("s 1", machineId)),
ignoreParseFailure(sessionsApi.setModel("s 1", "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel("s 1", "forward", machineId)),
ignoreParseFailure(sessionsApi.thinkingLevels("s 1", machineId)),
ignoreParseFailure(sessionsApi.setThinkingLevel("s 1", "medium", machineId)),
ignoreParseFailure(sessionsApi.cycleThinkingLevel("s 1", machineId)),
ignoreParseFailure(sessionsApi.commands("s 1", machineId)),
ignoreParseFailure(sessionsApi.prompt("s 1", "hello", "followUp", machineId)),
ignoreParseFailure(sessionsApi.shell("s 1", "ls", machineId)),
ignoreParseFailure(sessionsApi.runCommand("s 1", "/help", machineId)),
ignoreParseFailure(sessionsApi.respondToCommand("s 1", "req 1", "yes", machineId)),
ignoreParseFailure(sessionsApi.abort("s 1", machineId)),
ignoreParseFailure(sessionsApi.stop("s 1", machineId)),
ignoreParseFailure(sessionsApi.archive("s 1", machineId)),
ignoreParseFailure(sessionsApi.archiveWithDescendants("s 1", machineId)),
ignoreParseFailure(sessionsApi.restore("s 1", machineId)),
ignoreParseFailure(sessionsApi.detachParent("s 1", machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)),
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
ignoreParseFailure(sessionsApi.thinkingLevels(session, machineId)),
ignoreParseFailure(sessionsApi.setThinkingLevel(session, "medium", machineId)),
ignoreParseFailure(sessionsApi.cycleThinkingLevel(session, machineId)),
ignoreParseFailure(sessionsApi.commands(session, machineId)),
ignoreParseFailure(sessionsApi.prompt(session, "hello", "followUp", machineId)),
ignoreParseFailure(sessionsApi.shell(session, "ls", machineId)),
ignoreParseFailure(sessionsApi.runCommand(session, "/help", machineId)),
ignoreParseFailure(sessionsApi.respondToCommand(session, "req 1", "yes", machineId)),
ignoreParseFailure(sessionsApi.abort(session, machineId)),
ignoreParseFailure(sessionsApi.stop(session, machineId)),
ignoreParseFailure(sessionsApi.archive(session, machineId)),
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
ignoreParseFailure(sessionsApi.restore(session, machineId)),
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
ignoreParseFailure(sessionsApi.logoutProvider("openai", machineId)),
@@ -94,7 +95,7 @@ describe("federated route contract", () => {
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("location", { protocol: "https:", host: "pi.example.test" });
sessionEvents("s 1", machineId);
sessionEvents(session, machineId);
globalSessionEvents(machineId);
realtimeEvents(machineId);
terminalSocket("p 1", "w 1", "t 1", { cols: 120, rows: 40 }, machineId);
+2 -2
View File
@@ -19,12 +19,12 @@ afterEach(() => {
describe("machine-scoped socket urls", () => {
it("defaults session sockets to the local machine scope", () => {
sessionEvents("s1");
sessionEvents({ id: "s1", cwd: "/repo" });
globalSessionEvents();
realtimeEvents();
expect(webSocketUrls).toEqual([
"wss://pi.example.test/api/machines/local/sessions/s1/events",
"wss://pi.example.test/api/machines/local/sessions/s1/events?cwd=%2Frepo",
"wss://pi.example.test/api/machines/local/sessions/events",
"wss://pi.example.test/api/machines/local/events",
]);
+5 -2
View File
@@ -1,5 +1,8 @@
export function sessionEvents(sessionId: string, machineId = "local"): WebSocket {
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${sessionId}/events`);
import type { SessionRef } from "../../../shared/apiTypes";
export function sessionEvents(session: SessionRef, machineId = "local"): WebSocket {
const query = new URLSearchParams({ cwd: session.cwd }).toString();
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}/events?${query}`);
}
export function globalSessionEvents(machineId = "local"): WebSocket {
+5 -4
View File
@@ -1,3 +1,5 @@
import type { SessionRef } from "../../../shared/apiTypes";
export function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
const params = new URLSearchParams();
if (options?.path !== undefined) params.set("path", options.path);
@@ -14,12 +16,11 @@ export function machineGitDiffUrl(machineId: string, projectId: string, workspac
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
}
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }, machineId = "local"): string {
const params = new URLSearchParams();
export function messageUrl(session: SessionRef, options?: { limit?: number; before?: number }, machineId = "local"): string {
const params = new URLSearchParams({ cwd: session.cwd });
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.before !== undefined) params.set("before", String(options.before));
const query = params.toString();
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(session.id)}/messages?${params.toString()}`;
}
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
+2 -2
View File
@@ -173,8 +173,8 @@ export class PromptEditor extends LitElement {
this.completions = [];
return;
}
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") {
const commands = await api.commands(this.sessionId, this.machineId).catch(emptySlashCommands);
if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "" && this.cwd !== undefined && this.cwd !== "") {
const commands = await api.commands({ id: this.sessionId, cwd: this.cwd }, this.machineId).catch(emptySlashCommands);
if (version !== this.requestVersion) return;
this.completions = commands
.filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase()))
+5 -5
View File
@@ -244,19 +244,19 @@ export class AuthController {
}
private async refreshStatus(): Promise<void> {
const sessionId = this.sessionId();
if (sessionId === undefined) return;
const session = this.session();
if (session === undefined) return;
try {
this.applyStatus(await this.api.status(sessionId, selectedMachineId(this.getState())));
this.applyStatus(await this.api.status(session, selectedMachineId(this.getState())));
} catch {
// Status refresh is opportunistic after login completes.
}
}
private sessionId(): string | undefined {
private session() {
const session = this.getState().selectedSession;
if (session === undefined || session.archived === true) return undefined;
return session.id;
return session;
}
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionStatus, type Workspace } from "../api";
import { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
import { initialAppState, type AppState } from "../appState";
import { machineSessionKey } from "../machineKeys";
@@ -38,8 +38,8 @@ class MemoryStorage implements Storage {
class FakeSocket implements SessionEventSocket {
readonly connectedSessionIds: string[] = [];
connect(sessionId: string): void {
this.connectedSessionIds.push(sessionId);
connect(session: SessionRef): void {
this.connectedSessionIds.push(session.id);
}
setHandler(): void {
@@ -179,11 +179,11 @@ describe("SessionController", () => {
const api: typeof defaultApi = {
...defaultApi,
startSession: () => Promise.resolve(replacementSession),
messages: (sessionId) => {
if (sessionId === oldSession.id) return Promise.reject(new Error("Session not found"));
messages: (session) => {
if (session.id === oldSession.id) return Promise.reject(new Error("Session not found"));
return Promise.resolve(emptyPage);
},
status: (sessionId) => Promise.resolve(status(sessionId)),
status: (session) => Promise.resolve(status(session.id)),
};
const controller = new SessionController(
() => state,
@@ -220,7 +220,7 @@ describe("SessionController", () => {
...defaultApi,
respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }),
messages: () => Promise.resolve(emptyPage),
status: (sessionId) => Promise.resolve(status(sessionId)),
status: (session) => Promise.resolve(status(session.id)),
};
const controller = new SessionController(
() => state,
@@ -243,7 +243,7 @@ describe("SessionController", () => {
...defaultApi,
archive: () => Promise.resolve({ archived: true }),
messages: () => Promise.resolve(emptyPage),
status: (sessionId) => Promise.resolve(status(sessionId)),
status: (session) => Promise.resolve(status(session.id)),
};
const controller = new SessionController(
() => state,
@@ -272,7 +272,7 @@ describe("SessionController", () => {
...defaultApi,
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [oldSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
messages: () => Promise.resolve(emptyPage),
status: (sessionId) => Promise.resolve(status(sessionId)),
status: (session) => Promise.resolve(status(session.id)),
};
const controller = new SessionController(
() => state,
+26 -24
View File
@@ -1,4 +1,4 @@
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus, type ThinkingLevel } from "../api";
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type ThinkingLevel } from "../api";
import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages";
@@ -14,7 +14,7 @@ import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from
const MESSAGE_PAGE_SIZE = 100;
export interface SessionEventSocket {
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId?: string): void;
setHandler(onEvent: (event: SessionUiEvent) => void): void;
close(): void;
}
@@ -116,7 +116,7 @@ export class SessionController {
});
try {
if (session.archived === true) {
const page = await this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(transcriptKey, page);
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
@@ -125,12 +125,12 @@ export class SessionController {
}
const buffered: SessionUiEvent[] = [];
this.socket.connect(
session.id,
session,
(event) => buffered.push(event),
() => { void this.refreshSelectedSession(session.id); },
selectedMachineId(this.getState()),
);
const [page, status] = await Promise.all([this.api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session.id, selectedMachineId(this.getState()))]);
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(transcriptKey, page);
const isReceivingPartialStream = status.isStreaming;
@@ -156,7 +156,7 @@ export class SessionController {
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
this.setState({ isLoadingEarlierMessages: true });
try {
const page = await this.api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
const page = await this.api.messages(session, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
if (this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(this.sessionCacheKey(session.id), page);
this.setState(history);
@@ -174,7 +174,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
await this.api.prompt(session.id, text, streamingBehavior, selectedMachineId(this.getState()));
await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ error: String(error) });
@@ -186,7 +186,7 @@ export class SessionController {
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
await this.api.shell(session.id, text, selectedMachineId(this.getState()));
await this.api.shell(session, text, selectedMachineId(this.getState()));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
@@ -198,7 +198,7 @@ export class SessionController {
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
try {
this.applyCommandResult(await this.api.runCommand(session.id, text, selectedMachineId(this.getState())));
this.applyCommandResult(await this.api.runCommand(session, text, selectedMachineId(this.getState())));
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ messages: [...this.getState().messages, textMessage("system", String(error))], error: String(error) });
@@ -210,7 +210,7 @@ export class SessionController {
if (!session) return;
this.setState({ commandDialog: undefined });
try {
this.applyCommandResult(await this.api.respondToCommand(session.id, requestId, value, selectedMachineId(this.getState())));
this.applyCommandResult(await this.api.respondToCommand(session, requestId, value, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -231,7 +231,7 @@ export class SessionController {
return;
}
try {
await this.api.archive(session.id, selectedMachineId(this.getState()));
await this.api.archive(session, selectedMachineId(this.getState()));
const state = this.getState();
const sessions = markSessionArchived(state.sessions, session.id, new Date().toISOString());
const selectionChange = selectionAfterArchivingSession(sessions, state.selectedSession?.id, session.id);
@@ -247,7 +247,7 @@ export class SessionController {
async archiveSessionWithDescendants(session = this.getState().selectedSession) {
if (!session || isCachedNewSessionInfo(session)) return;
try {
const response = await this.api.archiveWithDescendants(session.id, selectedMachineId(this.getState()));
const response = await this.api.archiveWithDescendants(session, selectedMachineId(this.getState()));
const archivedIds = response.sessionIds !== undefined && response.sessionIds.length > 0 ? response.sessionIds : [session.id];
const state = this.getState();
const sessions = markSessionsArchived(state.sessions, archivedIds, new Date().toISOString());
@@ -263,7 +263,7 @@ export class SessionController {
async deleteCachedNewSession(session = this.getState().selectedSession) {
if (!isCachedNewSessionInfo(session)) return;
void this.api.stop(session.id, selectedMachineId(this.getState())).catch(() => {
void this.api.stop(session, selectedMachineId(this.getState())).catch(() => {
// Best-effort cleanup for browser-cached sessions that may not exist server-side anymore.
});
forgetCachedNewSession(session.id, selectedMachineId(this.getState()));
@@ -282,7 +282,7 @@ export class SessionController {
async restoreSession(session = this.getState().selectedSession) {
if (!session) return;
try {
await this.api.restore(session.id, selectedMachineId(this.getState()));
await this.api.restore(session, selectedMachineId(this.getState()));
const restored = { ...session };
delete restored.archived;
delete restored.archivedAt;
@@ -296,7 +296,7 @@ export class SessionController {
async detachParent(session = this.getState().selectedSession) {
if (session?.parentSessionPath === undefined) return;
try {
await this.api.detachParent(session.id, selectedMachineId(this.getState()));
await this.api.detachParent(session, selectedMachineId(this.getState()));
const detached = { ...session };
delete detached.parentSessionPath;
this.replaceSession(detached);
@@ -309,7 +309,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return [];
try {
return (await this.api.models(session.id, selectedMachineId(this.getState()))).models;
return (await this.api.models(session, selectedMachineId(this.getState()))).models;
} catch (error) {
this.setState({ error: String(error) });
return [];
@@ -320,7 +320,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.setModel(session.id, provider, modelId, selectedMachineId(this.getState())));
this.applyStatus(await this.api.setModel(session, provider, modelId, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -330,7 +330,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.cycleModel(session.id, direction, selectedMachineId(this.getState())));
this.applyStatus(await this.api.cycleModel(session, direction, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -340,7 +340,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return [];
try {
return (await this.api.thinkingLevels(session.id, selectedMachineId(this.getState()))).levels;
return (await this.api.thinkingLevels(session, selectedMachineId(this.getState()))).levels;
} catch (error) {
this.setState({ error: String(error) });
return [];
@@ -351,7 +351,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.setThinkingLevel(session.id, level, selectedMachineId(this.getState())));
this.applyStatus(await this.api.setThinkingLevel(session, level, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -361,7 +361,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
this.applyStatus(await this.api.cycleThinkingLevel(session.id, selectedMachineId(this.getState())));
this.applyStatus(await this.api.cycleThinkingLevel(session, selectedMachineId(this.getState())));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -371,7 +371,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (!session) return;
try {
await this.api.abort(session.id, selectedMachineId(this.getState()));
await this.api.abort(session, selectedMachineId(this.getState()));
} catch (error) {
this.setState({ error: String(error) });
}
@@ -382,7 +382,7 @@ export class SessionController {
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
try {
this.flushPendingTranscriptEvents();
const [page, status] = await Promise.all([this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(sessionId, selectedMachineId(this.getState()))]);
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page);
this.setState({
@@ -548,7 +548,9 @@ export class SessionController {
private async refreshMessages(sessionId: string) {
try {
const page = await this.api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
const session = this.getState().selectedSession;
if (session?.id !== sessionId) return;
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
if (this.getState().selectedSession?.id !== sessionId) return;
this.setState(this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page));
} catch (error) {
+7 -7
View File
@@ -1,11 +1,11 @@
import { globalSessionEvents, realtimeEvents, sessionEvents } from "./api";
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
export class SessionSocket {
private socket: WebSocket | undefined;
private sessionId: string | undefined;
private session: SessionRef | undefined;
private onEvent: ((event: SessionUiEvent) => void) | undefined;
private reconnectTimer?: number;
private reconnectDelay = 500;
@@ -14,10 +14,10 @@ export class SessionSocket {
private onReconnect: (() => void) | undefined;
private machineId = "local";
connect(sessionId: string, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void, onReconnect?: () => void, machineId = "local"): void {
this.close();
this.machineId = machineId;
this.sessionId = sessionId;
this.session = session;
this.onEvent = onEvent;
this.onReconnect = onReconnect;
this.shouldReconnect = true;
@@ -33,7 +33,7 @@ export class SessionSocket {
window.clearTimeout(this.reconnectTimer);
closeSocketQuietly(this.socket);
this.socket = undefined;
this.sessionId = undefined;
this.session = undefined;
this.onEvent = undefined;
this.onReconnect = undefined;
this.hasOpened = false;
@@ -41,8 +41,8 @@ export class SessionSocket {
}
private open(): void {
if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return;
const socket = sessionEvents(this.sessionId, this.machineId);
if (this.session === undefined || this.session.id === "" || this.session.cwd === "" || !this.shouldReconnect) return;
const socket = sessionEvents(this.session, this.machineId);
this.socket = socket;
socket.onopen = () => {
this.reconnectDelay = 500;