Merge branch 'main' into cleanup/plugin-api-scope

This commit is contained in:
Federico Jaramillo Martinez
2026-06-24 08:49:49 +02:00
102 changed files with 7292 additions and 1406 deletions
+21 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
import { filesApi, machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -84,6 +84,26 @@ describe("session API compatibility", () => {
});
});
describe("machine-scoped file suggestion API", () => {
it("uses the workspace-scoped route when the caller has enabled workspace-scoped suggestions", async () => {
const fetchMock = stubJsonFetch([]);
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a", workspaceScoped: true });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/projects/p%201/workspaces/w%2F1/files?q=README&scope=tracked");
});
it("falls back to the legacy cwd route when workspace-scoped suggestions are not enabled", async () => {
const fetchMock = stubJsonFetch([]);
await filesApi.files("/repo", "README", { projectId: "p 1", workspaceId: "w/1", scope: "tracked", machineId: "remote a" });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/files?q=README&scope=tracked&cwd=%2Frepo");
});
});
describe("machine-scoped terminal command-run API", () => {
it("deletes workspaces through the selected machine scope", async () => {
const fetchMock = stubJsonFetch(commandRun);
+8 -1
View File
@@ -238,14 +238,21 @@ export interface FileSuggestionQueryOptions {
mode?: "file" | "path" | undefined;
scope?: "tracked" | "all" | undefined;
machineId?: string | undefined;
projectId?: string | undefined;
workspaceId?: string | undefined;
workspaceScoped?: boolean | undefined;
}
export const filesApi = {
files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => {
const params = new URLSearchParams({ cwd, q: query });
const params = new URLSearchParams({ q: query });
if (options.kind !== undefined) params.set("kind", options.kind);
if (options.mode !== undefined) params.set("mode", options.mode);
if (options.scope !== undefined) params.set("scope", options.scope);
if (options.workspaceScoped === true && options.projectId !== undefined && options.workspaceId !== undefined) {
return request(`${machinePrefix(options.machineId)}/projects/${encodeURIComponent(options.projectId)}/workspaces/${encodeURIComponent(options.workspaceId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
}
params.set("cwd", cwd);
return request(`${machinePrefix(options.machineId)}/files?${params.toString()}`, arrayOf(parseFileSuggestion));
},
};
@@ -41,6 +41,7 @@ describe("federated route contract", () => {
ignoreParseFailure(workspacesApi.deleteWorkspaceFile("p 1", "w 1", "README.md", machineId)),
ignoreParseFailure(workspacesApi.moveWorkspaceFile("p 1", "w 1", "README.md", "docs/README.md", { overwrite: false }, machineId)),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", machineId })),
ignoreParseFailure(filesApi.files("/repo", "README", { kind: "tracked", mode: "file", projectId: "p 1", workspaceId: "w 1", machineId, workspaceScoped: true })),
ignoreParseFailure(gitApi.gitStatus("p 1", "w 1", machineId)),
ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)),
ignoreParseFailure(sessionsApi.sessions("/repo", machineId)),
@@ -64,6 +65,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.archiveWithDescendants(session, machineId)),
ignoreParseFailure(sessionsApi.restore(session, machineId)),
ignoreParseFailure(sessionsApi.deleteArchived(session, machineId)),
ignoreParseFailure(sessionsApi.reloadSession(session, machineId)),
ignoreParseFailure(sessionsApi.detachParent(session, machineId)),
ignoreParseFailure(sessionsApi.authProviders({ mode: "login", authType: "oauth", machineId })),
ignoreParseFailure(sessionsApi.saveApiKey("openai", "key", machineId)),
+6 -6
View File
@@ -7,15 +7,15 @@ describe("API parsers", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
envOverrides: { host: true, port: false, allowedHosts: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
})).toEqual({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true },
envOverrides: { host: true, port: false, allowedHosts: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
});
});
+36 -2
View File
@@ -473,16 +473,43 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])),
...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])),
...optionalField("plugins", optionalPlugins(record["plugins"])),
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
};
}
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
if (value === undefined) return undefined;
if (value === true) return true;
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value;
if (isStringArray(value)) return value;
throw new Error("Invalid PI WEB allowedHosts field");
}
function optionalPathAccess(value: unknown): PiWebConfigValues["pathAccess"] | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) throw new Error("Invalid PI WEB pathAccess field");
const allowedPaths = value["allowedPaths"];
return {
...optionalField("allowedPaths", optionalStringArray(allowedPaths, "pathAccess.allowedPaths")),
};
}
function optionalStringArray(value: unknown, field: string): string[] | undefined {
if (value === undefined) return undefined;
if (isNonEmptyStringArray(value)) return value;
throw new Error(`Invalid PI WEB ${field} field`);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
}
function optionalShortcuts(value: unknown): PiWebShortcutConfig | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB shortcuts field");
@@ -507,7 +534,7 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined {
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
const record = requireRecord(value);
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") };
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
}
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
@@ -751,6 +778,13 @@ export function parseReloaded(value: unknown): { reloaded: true } {
return { reloaded: true };
}
function optionalBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB ${key} field`);
return value;
}
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (value === undefined) return undefined;
+2 -2
View File
@@ -162,9 +162,9 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
if (type === "toolCall") {
const toolName = getString(part, "name") ?? "tool";
const args = getProperty(part, "arguments");
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead }];
const toolCallId = getString(part, "id");
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead, ...(toolCallId === undefined ? {} : { toolCallId }) }];
return [{ type: "toolCall", ...(toolCallId === undefined ? {} : { toolCallId }), toolName, summary: summarizeArgs(args), ...(args === undefined ? {} : { args }) }];
}
if (type === "image") {
+72 -3
View File
@@ -101,8 +101,9 @@ describe("applyTranscriptEvent", () => {
]);
});
it("replaces streamed skill reads when the finalized assistant message includes thinking", () => {
it("replaces streamed thinking and skill reads when the finalized assistant message includes thinking", () => {
const streamed: ChatLine[] = [
{ role: "assistant", parts: [{ type: "thinking", text: "load skill" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
{ role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "skill content", isError: false }] },
];
@@ -174,8 +175,8 @@ describe("applyTranscriptEvent", () => {
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "2", summary: "", args: { path: "/skills/sentry-cli/SKILL.md" } }) ?? messages;
expect(messages).toEqual([
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "sentry-cli", path: "/skills/sentry-cli/SKILL.md" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md", toolCallId: "1" }] },
{ role: "skill", parts: [{ type: "skillRead", name: "sentry-cli", path: "/skills/sentry-cli/SKILL.md", toolCallId: "2" }] },
]);
});
@@ -185,6 +186,74 @@ describe("applyTranscriptEvent", () => {
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages;
expect(messages).toEqual([
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md", toolCallId: "1" }] },
]);
});
it("replaces multiple streamed skill reads with the finalized grouped skill message", () => {
const firstTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-1", toolName: "read", summary: "/skills/code-quality-architecture/SKILL.md", status: "success", resultText: "content" }] };
const secondTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-2", toolName: "read", summary: "/skills/relay/SKILL.md", status: "success", resultText: "content" }] };
const thirdTool: ChatLine = { role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-3", toolName: "read", summary: "/skills/skill-creator/SKILL.md", status: "success", resultText: "content" }] };
const streamed: ChatLine[] = [
{ role: "skill", parts: [{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" }] },
firstTool,
{ role: "skill", parts: [{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" }] },
secondTool,
{ role: "skill", parts: [{ type: "skillRead", name: "skill-creator", path: "/skills/skill-creator/SKILL.md", toolCallId: "read-3" }] },
thirdTool,
];
expect(applyTranscriptEvent(streamed, {
type: "message.end",
message: {
role: "assistant",
content: [
{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "/skills/code-quality-architecture/SKILL.md" } },
{ type: "toolCall", id: "read-2", name: "read", arguments: { path: "/skills/relay/SKILL.md" } },
{ type: "toolCall", id: "read-3", name: "read", arguments: { path: "/skills/skill-creator/SKILL.md" } },
],
timestamp: "2026-05-09T12:00:00.000Z",
},
})).toEqual([
{
role: "skill",
parts: [
{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" },
{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" },
{ type: "skillRead", name: "skill-creator", path: "/skills/skill-creator/SKILL.md", toolCallId: "read-3" },
],
meta: { timestamp: "2026-05-09T12:00:00.000Z" },
},
firstTool,
secondTool,
thirdTool,
]);
});
it("ignores streamed skill read starts that are already in a finalized grouped skill message", () => {
const messages: ChatLine[] = [
{
role: "skill",
parts: [
{ type: "skillRead", name: "code-quality-architecture", path: "/skills/code-quality-architecture/SKILL.md", toolCallId: "read-1" },
{ type: "skillRead", name: "relay", path: "/skills/relay/SKILL.md", toolCallId: "read-2" },
],
meta: { timestamp: "2026-05-09T12:00:00.000Z" },
},
{ role: "tool", parts: [{ type: "toolExecution", toolCallId: "read-1", toolName: "read", summary: "/skills/code-quality-architecture/SKILL.md", status: "success", resultText: "content" }] },
];
expect(applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "read-2", summary: "", args: { path: "/skills/relay/SKILL.md" } })).toEqual(messages);
});
it("allows the same skill read after a user boundary", () => {
const messages: ChatLine[] = [
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
textMessage("user", "load it again"),
];
expect(applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "", summary: "", args: { path: "/skills/playwright/SKILL.md" } })).toEqual([
...messages,
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
]);
});
+71 -12
View File
@@ -35,8 +35,8 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
}
function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] {
const skillReadIndex = findMatchingSkillRead(messages, displayEnded);
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), displayEnded, ...messages.slice(skillReadIndex + 1)];
const skillReadIndexes = findMatchingSkillReadIndexes(messages, displayEnded);
if (skillReadIndexes.length > 0) return replaceSkillReadLines(messages, skillReadIndexes, displayEnded);
const last = messages.at(-1);
if (last?.role !== displayEnded.role) return [...messages, displayEnded];
if (displayEnded.role === "assistant" || sameMessageText(last, displayEnded)) return [...messages.slice(0, -1), displayEnded];
@@ -58,7 +58,9 @@ function parseSkillReadPath(path: string | undefined): { name: string; path: str
function appendToolExecutionStart(messages: ChatLine[], event: Extract<SessionUiEvent, { type: "tool.start" }>): ChatLine[] {
const skillRead = event.toolName === "read" ? parseSkillReadPath(getString(event.args, "path")) : undefined;
if (skillRead !== undefined) return appendLine(messages, { role: "skill", parts: [{ type: "skillRead", ...skillRead }] });
if (skillRead !== undefined) {
return appendLine(messages, { role: "skill", parts: [{ type: "skillRead", ...skillRead, ...(event.toolCallId === "" ? {} : { toolCallId: event.toolCallId }) }] });
}
const part: ToolExecutionPart = {
type: "toolExecution",
@@ -151,16 +153,51 @@ function stringifyToolContent(content: unknown): string {
return "";
}
function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number {
function findMatchingSkillReadIndexes(messages: ChatLine[], ended: ChatLine): number[] {
const endedReads = skillReads(ended);
if (endedReads.length === 0) return -1;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.role !== "skill") continue;
const reads = skillReads(message);
if (sameSkillReads(reads, endedReads)) return index;
if (endedReads.length === 0) return [];
const matchedIndexes: number[] = [];
let readEnd = endedReads.length;
const lowerBound = lastUserBoundaryIndex(messages) + 1;
for (let index = messages.length - 1; index >= lowerBound; index--) {
const reads = skillReads(messages[index]);
if (reads.length === 0) continue;
const readStart = readEnd - reads.length;
if (readStart < 0) continue;
if (!sameSkillReads(reads, endedReads.slice(readStart, readEnd))) continue;
matchedIndexes.unshift(index);
readEnd = readStart;
if (readEnd === 0) return matchedIndexes;
}
return -1;
return [];
}
function replaceSkillReadLines(messages: ChatLine[], indexes: number[], replacement: ChatLine): ChatLine[] {
const replacementIndexes = indexesWithAdjacentAssistantFragment(messages, indexes, replacement);
const insertIndex = replacementIndexes[0];
if (insertIndex === undefined) return messages;
const replaced = new Set(replacementIndexes);
const next: ChatLine[] = [];
for (let index = 0; index < messages.length; index++) {
if (index === insertIndex) next.push(replacement);
const message = messages[index];
if (message !== undefined && !replaced.has(index)) next.push(message);
}
return next;
}
function indexesWithAdjacentAssistantFragment(messages: ChatLine[], indexes: number[], replacement: ChatLine): number[] {
const firstIndex = indexes[0];
if (replacement.role !== "assistant" || firstIndex === undefined) return indexes;
const previousIndex = firstIndex - 1;
return isStreamedAssistantFragment(messages[previousIndex]) ? [previousIndex, ...indexes] : indexes;
}
function isStreamedAssistantFragment(message: ChatLine | undefined): boolean {
return message?.role === "assistant" && message.parts.length > 0 && message.parts.every((part) => part.type === "text" || part.type === "thinking");
}
function skillReads(message: ChatLine | undefined): SkillRead[] {
@@ -176,6 +213,7 @@ function sameSkillReads(left: SkillRead[], right: SkillRead[]): boolean {
function sameSkillRead(left: SkillRead, right: SkillRead | undefined): boolean {
if (right === undefined) return false;
if (left.toolCallId !== undefined && right.toolCallId !== undefined) return left.toolCallId === right.toolCallId;
return normalizeSkillPath(left.path) === normalizeSkillPath(right.path) || left.name === right.name;
}
@@ -201,11 +239,32 @@ function appendNewMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[]
function appendLine(messages: ChatLine[], line: ChatLine): ChatLine[] {
const last = messages.at(-1);
if (line.role === "skill" && sameSkillReads(skillReads(last), skillReads(line))) return messages;
if (isDuplicateSkillLine(messages, line)) return messages;
if (last?.role === line.role && line.role !== "skill") return [...messages.slice(0, -1), { ...last, parts: [...last.parts, ...line.parts] }];
return [...messages, line];
}
function isDuplicateSkillLine(messages: ChatLine[], line: ChatLine): boolean {
const reads = skillReads(line);
if (line.role !== "skill" || reads.length === 0) return false;
const lowerBound = lastUserBoundaryIndex(messages) + 1;
return reads.every((read) => hasMatchingSkillRead(messages, read, lowerBound));
}
function hasMatchingSkillRead(messages: ChatLine[], read: SkillRead, lowerBound: number): boolean {
for (let index = messages.length - 1; index >= lowerBound; index--) {
if (skillReads(messages[index]).some((candidate) => sameSkillRead(candidate, read))) return true;
}
return false;
}
function lastUserBoundaryIndex(messages: ChatLine[]): number {
for (let index = messages.length - 1; index >= 0; index--) {
if (messages[index]?.role === "user") return index;
}
return -1;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+21 -3
View File
@@ -152,6 +152,7 @@ export class PiWebApp extends LitElement {
private readonly handledWorkspaceDeletionRunIds = new Set<string>();
private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>();
private machineNavigationRestoreSeq = 0;
private navigationSelectionSeq = 0;
private routeRestoreSeq = 0;
private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined;
@@ -575,12 +576,16 @@ export class PiWebApp extends LitElement {
if (tool === "core:workspace.git") await this.git.refreshGit();
}
private async withChatScrollTransition(action: () => Promise<void>) {
private async withChatScrollTransition(action: () => Promise<void>, shouldComplete: () => boolean = () => true) {
this.chatView?.saveScrollPosition();
await action();
if (!shouldComplete()) return;
await this.updateComplete;
if (!shouldComplete()) return;
await this.chatView?.updateComplete;
if (!shouldComplete()) return;
await nextFrame();
if (!shouldComplete()) return;
this.chatView?.restoreScrollPosition();
if (this.shouldAutoFocusPrompt()) this.promptEditor?.focusInput();
}
@@ -1004,6 +1009,14 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
}
private supportsWorkspaceFileSuggestions(machineId = selectedMachineId(this.state)): boolean {
if (machineId === "local") return true;
// COMPAT-CAP workspace.fileSuggestions: remote machines without this
// capability stay on the legacy cwd-based /files route.
const runtime = this.state.machineRuntimes[machineId];
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.workspaceFileSuggestions);
}
private archivedDeleteUnavailableMessage(): string {
const machineName = this.state.selectedMachine?.name ?? "this machine";
return `Update and restart Pi-Web on ${machineName} to delete archived sessions.`;
@@ -1078,10 +1091,15 @@ export class PiWebApp extends LitElement {
}
private async selectNavigationItem(section: NavigationSection, nextTarget: NavigationFocusTarget, action: () => Promise<void>): Promise<void> {
const seq = ++this.navigationSelectionSeq;
const isCurrentSelection = () => seq === this.navigationSelectionSeq;
await this.withChatScrollTransition(async () => {
this.navigationSections.advanceAfterSelection(section);
await action();
});
}, isCurrentSelection);
if (!isCurrentSelection()) return;
await this.focusNavigationTarget(nextTarget);
}
@@ -1779,7 +1797,7 @@ export class PiWebApp extends LitElement {
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
+5 -2
View File
@@ -33,6 +33,9 @@ export class PromptEditor extends LitElement {
@property() sessionId?: string;
@property() cwd?: string;
@property() machineId = "local";
@property() projectId?: string;
@property() workspaceId?: string;
@property({ type: Boolean }) workspaceScopedFileSuggestions = false;
@property({ type: Boolean }) canSteer = false;
@property({ type: Boolean }) isCompacting = false;
@property({ type: Boolean }) canStop = false;
@@ -146,7 +149,7 @@ export class PromptEditor extends LitElement {
<label class="attachment-delivery" title="How attachments are delivered to the agent">
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline">Attach to message</option>
<option value="folder">Save to .pi-web/paste</option>
<option value="folder">Save to .pi-web/attachments</option>
</select>
</label>
` : null}
@@ -298,7 +301,7 @@ export class PromptEditor extends LitElement {
...(command.description === undefined ? {} : { description: command.description }),
}));
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions);
const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId, projectId: this.projectId, workspaceId: this.workspaceId, workspaceScoped: this.workspaceScopedFileSuggestions }).catch(emptyFileSuggestions);
if (version !== this.requestVersion) return;
this.completions = files
.slice(0, 12)
+2 -2
View File
@@ -228,10 +228,10 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete archived session" : this.archivedDeleteUnavailableMessage} ?disabled=${!this.canDeleteArchived} @click=${() => { this.openMenuSessionId = undefined; this.confirmDeleteArchived(session); }}>Delete archived session</button>
`
: html`
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
<button title="Archive session" @click=${() => { this.openMenuSessionId = undefined; this.onArchive?.(session); }}>Archive</button>
${descendantCount > 0 ? html`<button title="Archive this session and its descendants" @click=${() => { this.openMenuSessionId = undefined; this.confirmArchiveWithDescendants(session, descendantCount); }}>Archive with descendants (${descendantCount})</button>` : null}
${session.parentSessionPath !== undefined ? html`<button title="Detach from parent" @click=${() => { this.openMenuSessionId = undefined; this.onDetachParent?.(session); }}>Detach from parent</button>` : null}
${this.canReload ? html`<button title=${isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "Stop current session activity before reloading" : "Reload session from disk"} ?disabled=${isSessionActive(this.statuses[session.id], this.activities[session.id])} @click=${() => { this.openMenuSessionId = undefined; this.onReload?.(session); }}>Reload</button>` : null}
`}
</div>
` : null}
@@ -4,6 +4,7 @@ import type { AppAction } from "../actions";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel";
import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel";
@@ -47,6 +48,7 @@ export class SettingsDialog extends LitElement {
<div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
</nav>
@@ -60,6 +62,19 @@ export class SettingsDialog extends LitElement {
}
private renderActiveSection(): TemplateResult {
if (this.section === "sessiond") {
return html`
<settings-sessiond-panel
.configResponse=${this.configResponse}
.loading=${this.loading}
.saving=${this.saving}
.error=${this.error}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
></settings-sessiond-panel>
`;
}
if (this.section === "shortcuts") {
return html`
<settings-shortcuts-panel
+5 -1
View File
@@ -562,7 +562,11 @@ export class TerminalPanel extends LitElement {
.terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; }
.terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; }
.terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; }
.terminal-host .xterm-helper-textarea { position: absolute !important; left: -9999em !important; top: 0 !important; width: 0 !important; height: 0 !important; min-width: 0 !important; min-height: 0 !important; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5 !important; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; }
/* Hide the helper textarea without using !important on the positional properties (left/top/width/height/z-index). xterm sets those inline during IME/dead-key composition (e.g. "~" on a Swedish layout) so the composition is positioned at the cursor and committed correctly; forcing them here would pin the textarea off-screen with zero size and break composition. */
.terminal-host .xterm-helper-textarea { position: absolute; left: -9999em; top: 0; width: 0; height: 0; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; }
/* The composition view shows pending dead-key/IME input. Without these rules it renders as a static block in the top-left corner instead of overlaying the cursor. */
.terminal-host .composition-view { position: absolute; display: none; white-space: nowrap; z-index: 1; background: var(--pi-terminal-bg, #000); color: var(--pi-terminal-text, #fff); }
.terminal-host .composition-view.active { display: block; }
.terminal-host .xterm-viewport { position: absolute; inset: 0; overflow-y: scroll; cursor: default; background-color: var(--pi-terminal-bg); }
.terminal-host .xterm-screen { position: relative; }
.terminal-host .xterm-screen canvas { position: absolute; left: 0; top: 0; }
@@ -71,6 +71,14 @@ export class SettingsGeneralPanel extends LitElement {
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
</div>
<label class="field">
<span class="field-heading">
<span>External filesystem roots</span>
</span>
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs&#10;/opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
</label>
${this.renderEffectiveConfig()}
<footer class="form-actions">
@@ -102,6 +110,7 @@ export class SettingsGeneralPanel extends LitElement {
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
</dl>
</section>
`;
@@ -173,6 +182,11 @@ function formatAllowedHosts(value: PiWebConfigValues["allowedHosts"]): string |
return html`<span class="muted">Unset</span>`;
}
function formatAllowedPaths(value: string[] | undefined): string | TemplateResult {
if (value === undefined || value.length === 0) return html`<span class="muted">External paths denied</span>`;
return value.join(", ");
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
@@ -0,0 +1,142 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
@customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement {
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
override render(): TemplateResult {
const config = this.configResponse;
const spawnOverridden = config?.envOverrides.spawnSessions === true;
// On by default: the effective config is the source of truth for the toggle
// state, so an unset config file still shows the feature as enabled.
const effectiveSpawn = config?.effectiveConfig.spawnSessions !== false;
const subsessionsOverridden = config?.envOverrides.subsessions === true;
// Beta, off by default; also requires spawn to be enabled.
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
return html`
<div class="section-heading">
<div>
<h2>Session daemon</h2>
<p>These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div>
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config?.path ?? "Unknown"}</code>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSpawn}
?disabled=${this.loading || this.saving || spawnOverridden}
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
>
<span>Enable the <code>spawn_session</code> tool</span>
</label>
<small>When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default.</small>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start tracked subsessions</span>
<span class="beta-badge">beta</span>
${subsessionsOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSubsessions}
?disabled=${this.loading || this.saving || subsessionsOverridden || !effectiveSpawn}
@change=${(event: Event) => { void this.toggleSubsessions(event); }}
>
<span>Enable the <code>spawn_subsession</code> tools</span>
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
</section>
`}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
return null;
}
private async toggleSpawnSessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {};
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
}
private async toggleSubsessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
const baseConfig = this.configResponse?.config ?? {};
await this.onSave?.({ ...baseConfig, subsessions: enabled });
}
static override styles = css`
:host { display: block; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
h2, h3, p { margin: 0; }
h2 { font-size: 17px; line-height: 1.25; }
h3 { font-size: 13px; line-height: 1.3; }
p { color: var(--pi-muted); line-height: 1.45; }
button, input { font: inherit; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.secondary { flex: 0 0 auto; }
.message, .loading-card, .config-path-card, .effective-card, .restart-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.message { margin-bottom: 12px; }
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
.loading-card { color: var(--pi-muted); }
.restart-note { margin-bottom: 14px; border-color: var(--pi-warning-border); color: var(--pi-warning); background: var(--pi-warning-surface); line-height: 1.45; }
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.field { display: grid; gap: 7px; margin-bottom: 14px; }
.field small { color: var(--pi-muted); line-height: 1.45; }
.field-heading { display: flex; align-items: center; gap: 8px; }
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
.toggle input { width: 16px; height: 16px; }
.toggle input:disabled { cursor: not-allowed; }
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.effective-card { display: grid; gap: 10px; }
.effective-card dl { display: grid; gap: 8px; margin: 0; }
.effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; }
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.muted { color: var(--pi-muted); }
@media (max-width: 760px) {
.section-heading { display: grid; gap: 12px; }
.section-heading .secondary { justify-self: start; }
.effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; }
}
`;
}
@@ -3,27 +3,62 @@ import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
describe("settings config drafts", () => {
it("converts PI WEB config values to editable general settings drafts", () => {
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"] })).toEqual({
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
host: "0.0.0.0",
port: "8504",
allowedHostsMode: "list",
allowedHostsText: "example.local\n192.168.1.20",
allowedPathsText: "/tmp\n~/SDKs",
});
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
});
it("converts drafts back to config while preserving shortcut and plugin preferences", () => {
it("converts drafts back to config while preserving non-general preferences", () => {
expect(configFromDraft({
host: " 127.0.0.1 ",
port: "9000",
allowedHostsMode: "list",
allowedHostsText: "example.local, 192.168.1.20\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } } })).toEqual({
allowedPathsText: "/tmp\n~/SDKs\n",
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({
host: "127.0.0.1",
port: 9000,
allowedHosts: ["example.local", "192.168.1.20"],
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
plugins: { info: { enabled: false } },
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
maxUploadBytes: 1234,
});
});
it("removes global path access when the allowed paths field is cleared", () => {
expect(configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "",
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
});
it("rejects relative external paths before saving", () => {
expect(() => configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "relative/path",
})).toThrow("Allowed external paths must be absolute paths or start with ~");
});
it("preserves the spawnSessions flag when saving general settings", () => {
const result = configFromDraft({
host: "",
port: "",
allowedHostsMode: "list",
allowedHostsText: "",
allowedPathsText: "",
}, { spawnSessions: true });
expect(result.spawnSessions).toBe(true);
});
});
@@ -5,10 +5,11 @@ export interface ConfigDraft {
port: string;
allowedHostsMode: "list" | "all";
allowedHostsText: string;
allowedPathsText: string;
}
export function emptyConfigDraft(): ConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
}
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
@@ -17,6 +18,7 @@ export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
port: config.port === undefined ? "" : String(config.port),
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
};
}
@@ -24,6 +26,9 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
const config: PiWebConfigValues = {
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
};
const host = draft.host.trim();
const port = draft.port.trim();
@@ -34,9 +39,22 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
config.port = parsed;
}
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
return config;
}
function parseAllowedHostsText(value: string): string[] {
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
}
function parseAllowedPathsText(value: string): string[] {
const paths = value.split("\n").map((path) => path.trim()).filter((path) => path !== "");
const invalid = paths.find((path) => !isAbsoluteishAllowedPath(path));
if (invalid !== undefined) throw new Error(`Allowed external paths must be absolute paths or start with ~: ${invalid}`);
return paths;
}
function isAbsoluteishAllowedPath(path: string): boolean {
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
}
+4 -4
View File
@@ -24,7 +24,7 @@ export type ChatPart =
| { type: "image"; mimeType: string; data: string }
| { type: "thinking"; text: string }
| { type: "skillInvocation"; name: string; location: string; content: string }
| { type: "skillRead"; name: string; path: string }
| { type: "skillRead"; name: string; path: string; toolCallId?: string }
| { type: "toolCall"; toolCallId?: string; toolName: string; summary: string; args?: unknown }
| ToolExecutionPart
| { type: "toolResult"; toolCallId?: string; toolName: string; text: string; isError: boolean; content?: unknown; details?: unknown }
@@ -280,9 +280,9 @@ export const chatStyles = css`
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
.activity-dock.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; }
.msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-left: 3px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: visible; }
.msg.assistant { border-left-color: var(--pi-text-secondary); background: var(--pi-surface); }
.msg.user { border-color: var(--pi-accent-border); border-left: 3px solid var(--pi-accent); background: var(--pi-selection-bg); }
.msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: visible; }
.msg.assistant { background: var(--pi-surface); }
.msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
.msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
.msg.tool-execution-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); }
.msg.system { color: var(--pi-danger); }
@@ -142,6 +142,68 @@ describe("SessionController", () => {
expect(state.selectedSession?.messageCount).toBe(3);
});
it("adds a newly created session to the list when it belongs to the selected workspace", () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" };
controller.applyGlobalEvent({ type: "session.created", session: spawned });
expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]);
});
it("ignores a created session for a different workspace or a duplicate id", () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } });
controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } });
expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]);
});
it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => {
const storage = new MemoryStorage();
Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true });
const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" };
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] };
const socket = new FakeSocket();
const api: typeof defaultApi = {
...defaultApi,
startSession: () => {
// Simulate the broadcast arriving before the HTTP response resolves.
controller.applyGlobalEvent({ type: "session.created", session: started });
return Promise.resolve(started);
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket },
);
await controller.startSession();
expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]);
expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true);
});
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;
@@ -207,7 +269,7 @@ describe("SessionController", () => {
const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
const api: typeof defaultApi = {
...defaultApi,
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/paste/shot.png", mimeType: "image/png", size: 3 }]); },
saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/attachments/shot.png", mimeType: "image/png", size: 3 }]); },
prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
};
const controller = new SessionController(
@@ -221,7 +283,7 @@ describe("SessionController", () => {
await controller.send("check this", undefined, attachments, "folder");
expect(savedCalledWith).toEqual(attachments);
expect(promptText).toBe("check this\n\[email protected]/paste/shot.png");
expect(promptText).toBe("check this\n\[email protected]/attachments/shot.png");
expect(promptAttachments).toBeUndefined();
expect(state.sendingPrompts).toEqual({});
});
@@ -246,6 +308,36 @@ describe("SessionController", () => {
expect(state.sendingPrompts).toEqual({});
});
it("sends slash commands without inserting an optimistic transcript line and toggles the sending state", async () => {
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
let resolveCommand: (() => void) | undefined;
const seenDuringCommand: Record<string, true>[] = [];
const api: typeof defaultApi = {
...defaultApi,
runCommand: (_session, text) => new Promise((resolve) => {
seenDuringCommand.push({ ...state.sendingPrompts });
resolveCommand = () => { resolve(text.startsWith("/skill") ? { type: "done" } : { type: "done", message: "stats" }); };
}),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const run = controller.send("/skill:skill-creator");
expect(seenDuringCommand).toEqual([{ [oldSession.id]: true }]);
// No raw command text is added to the transcript; the agent streams the
// canonical expanded message back instead.
expect(state.messages).toEqual([]);
resolveCommand?.();
await run;
expect(state.messages).toEqual([]);
expect(state.sendingPrompts).toEqual({});
});
it("keeps live message count updates when a cached new session becomes persisted", async () => {
const cachedSession = markCachedNewSessionInfo(oldSession);
let resolvePrompt: (() => void) | undefined;
@@ -51,6 +51,7 @@ export class SessionController {
applyGlobalEvent(event: GlobalSessionEvent): void {
if (event.type === "status.update") this.applyStatus(event.status);
else if (event.type === "activity.update") this.applyActivity(event.activity);
else if (event.type === "session.created") this.applyCreatedSession(event.session);
else this.applySessionName(event.sessionId, event.name);
}
@@ -93,7 +94,10 @@ export class SessionController {
const session = await this.api.startSession(workspace.path, machineId);
rememberCachedNewSession(session, machineId);
const cachedSession = markCachedNewSessionInfo(session, machineId);
this.setState({ sessions: [cachedSession, ...this.getState().sessions] });
// 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)] });
await this.selectSession(cachedSession);
} catch (error) {
this.setState({ error: String(error) });
@@ -139,9 +143,7 @@ export class SessionController {
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;
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
this.setState({ ...history, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
this.setState({ ...history, isLoadingEarlierMessages: false, ...this.setStreamCatchup(status.isStreaming ? session.id : undefined), status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
this.applyStatus(status);
void this.refreshAvailableThinkingLevels();
for (const event of buffered) this.applyEvent(event);
@@ -231,12 +233,21 @@ export class SessionController {
async runCommand(text: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
this.setState({ messages: [...this.getState().messages, textMessage("user", text)] });
// Commands are not inserted into the transcript optimistically: a builtin
// command produces its own result line, and a runtime/skill command is
// forwarded to the agent, which streams back the canonical (expanded)
// message. Inserting the raw text here would leave a line that doesn't
// converge with server history and disappears on reload. Surface the same
// per-session sending indicator that send() uses for the pre-receipt window.
const sessionId = session.id;
this.markSendingPrompt(sessionId, true);
try {
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) });
} finally {
this.markSendingPrompt(sessionId, false);
}
}
@@ -508,7 +519,7 @@ export class SessionController {
...history,
status,
activity: this.getState().sessionActivities[sessionId],
isReceivingPartialStream: status.isStreaming,
...this.setStreamCatchup(status.isStreaming ? sessionId : undefined),
});
this.applyStatus(status);
} catch (error) {
@@ -576,6 +587,16 @@ export class SessionController {
}
}
private applyCreatedSession(session: SessionInfo) {
const state = this.getState();
// Only surface sessions for the workspace currently in view; others are
// picked up when their workspace is opened. Skip if already present (e.g.
// the optimistic insert from startSession in this same tab).
if (state.selectedWorkspace?.path !== session.cwd) return;
if (state.sessions.some((candidate) => candidate.id === session.id)) return;
this.setState({ sessions: [session, ...state.sessions] });
}
private applyActivity(activity: SessionActivity) {
this.setState({
sessionActivities: { ...this.getState().sessionActivities, [activity.sessionId]: activity },
@@ -593,7 +614,7 @@ export class SessionController {
status: state.selectedSession?.id === status.sessionId ? status : state.status,
activity: state.selectedSession?.id === status.sessionId && clearsStaleActivity ? undefined : state.activity,
});
if (this.catchupStreamSessionId === status.sessionId && !status.isStreaming) this.finishStreamCatchup(status.sessionId);
if (!status.isStreaming) this.finishStreamCatchup(status.sessionId);
}
private applySessionName(sessionId: string, name: string | undefined) {
@@ -664,10 +685,24 @@ export class SessionController {
this.pendingTranscriptFrame = undefined;
}
// Stream catch-up is a single mode with two coupled facets that must never
// drift: the private `catchupStreamSessionId` guard (which suppresses live
// transcript events while we lack the in-flight message prefix) and the
// public `isReceivingPartialStream` flag (which drives the "Catching up…"
// badge). Route every mutation of the mode through this helper so the guard
// and the badge can never disagree. Catch-up only ever applies to the
// selected session, so an active session id always implies the badge is on.
private setStreamCatchup(sessionId: string | undefined): Pick<AppState, "isReceivingPartialStream"> {
this.catchupStreamSessionId = sessionId;
return { isReceivingPartialStream: sessionId !== undefined };
}
private finishStreamCatchup(sessionId: string) {
if (this.catchupStreamSessionId !== sessionId) return;
const isSelected = this.getState().selectedSession?.id === sessionId;
const wasCatchingUp = this.catchupStreamSessionId === sessionId || (isSelected && this.getState().isReceivingPartialStream);
if (!wasCatchingUp) return;
this.catchupStreamSessionId = undefined;
if (this.getState().selectedSession?.id === sessionId) this.setState({ isReceivingPartialStream: false });
if (isSelected) this.setState({ isReceivingPartialStream: false });
void this.refreshMessages(sessionId);
}
+2 -2
View File
@@ -131,12 +131,12 @@ export class RealtimeSocket {
function isSessionUiEvent(event: unknown): event is SessionUiEvent {
const type = eventType(event);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "pi.event"].includes(type);
return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "session.created", "pi.event"].includes(type);
}
function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent {
const type = eventType(event);
return type === "status.update" || type === "activity.update" || type === "session.name";
return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created";
}
function isRealtimeEvent(event: unknown): event is RealtimeEvent {
+2
View File
@@ -35,6 +35,8 @@ function installWindow(href: string): { pushed: string[]; replaced: string[] } {
describe("settings route helpers", () => {
it("parses supported settings deep links and aliases", () => {
expect(parseSettingsSection("general")).toBe("general");
expect(parseSettingsSection("sessiond")).toBe("sessiond");
expect(parseSettingsSection("sessions")).toBe("sessiond");
expect(parseSettingsSection("plugins")).toBe("plugins");
expect(parseSettingsSection("shortcuts")).toBe("shortcuts");
expect(parseSettingsSection("keyboard")).toBe("shortcuts");
+2 -1
View File
@@ -1,4 +1,4 @@
export type SettingsSection = "general" | "plugins" | "shortcuts";
export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts";
export function readSettingsSection(): SettingsSection | undefined {
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
@@ -17,6 +17,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio
export function parseSettingsSection(value: string | null): SettingsSection | undefined {
if (value === "general") return "general";
if (value === "sessiond" || value === "sessions") return "sessiond";
if (value === "plugins") return "plugins";
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
return undefined;