Archived
feat(sessions): surface live session startup warnings in the web UI
Show a pinned banner at the top of the session view with resource and runtime diagnostics (skills, prompts, themes, extension load errors) plus the Anthropic subscription-auth billing notice, recomputed live from the current runtime so they stay accurate across browser reloads. Warnings carry an optional dismiss capability; the Anthropic notice is dismissable and durably suppressed through pi's own anthropicExtraUsage warning setting. Also fixes the testing-guide skill frontmatter so it loads.
This commit is contained in:
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
|
||||
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
|
||||
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
|
||||
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
|
||||
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
@@ -212,6 +212,7 @@ export const sessionsApi = {
|
||||
status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
|
||||
streamSnapshot: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "stream-snapshot", machineId), parseSessionStreamSnapshot),
|
||||
clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
|
||||
@@ -268,6 +268,59 @@ describe("API parsers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses live session warnings including optional source and path", () => {
|
||||
const parsed = parseSessionStatus({
|
||||
sessionId: "s1",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
warnings: [
|
||||
{ severity: "error", message: "bad skill", source: "skill", path: "/skills/a.md" },
|
||||
{ severity: "warning", message: "subscription active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
|
||||
{ severity: "info", message: "heads up", source: "runtime" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.warnings).toEqual([
|
||||
{ severity: "error", message: "bad skill", source: "skill", path: "/skills/a.md" },
|
||||
{ severity: "warning", message: "subscription active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
|
||||
{ severity: "info", message: "heads up", source: "runtime" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits warnings entirely when the field is absent", () => {
|
||||
const parsed = parseSessionStatus({
|
||||
sessionId: "s1",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
});
|
||||
|
||||
expect(parsed.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a warning with an invalid severity", () => {
|
||||
expect(() => parseSessionStatus({
|
||||
sessionId: "s1",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
warnings: [{ severity: "fatal", message: "nope" }],
|
||||
})).toThrow("Invalid session warning severity");
|
||||
});
|
||||
|
||||
it("parses workspace effective upload config when present", () => {
|
||||
expect(parseWorkspace({
|
||||
id: "w1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SessionStreamSnapshot, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SessionStreamSnapshot, SessionWarning, SessionWarningSeverity, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
|
||||
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
|
||||
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
|
||||
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
|
||||
@@ -177,6 +177,34 @@ export function parseSessionInfo(value: unknown): SessionInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionWarningSeverity(value: unknown): SessionWarningSeverity {
|
||||
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid session warning severity");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSessionWarningDismiss(value: unknown): { id: string } | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const record = requireRecord(value);
|
||||
return { id: requireString(record, "id") };
|
||||
}
|
||||
|
||||
function parseSessionWarning(value: unknown): SessionWarning {
|
||||
const record = requireRecord(value);
|
||||
const dismiss = parseSessionWarningDismiss(record["dismiss"]);
|
||||
return {
|
||||
severity: parseSessionWarningSeverity(record["severity"]),
|
||||
message: requireString(record, "message"),
|
||||
...optionalField("source", optionalString(record, "source")),
|
||||
...optionalField("path", optionalString(record, "path")),
|
||||
...(dismiss === undefined ? {} : { dismiss }),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalWarnings(value: unknown): Pick<SessionStatus, "warnings"> | object {
|
||||
if (value === undefined) return {};
|
||||
return { warnings: arrayOf(parseSessionWarning)(value) };
|
||||
}
|
||||
|
||||
export function parseSessionStatus(value: unknown): SessionStatus {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
@@ -193,6 +221,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
|
||||
...optionalModel(record["model"]),
|
||||
...optionalContextUsage(record["contextUsage"]),
|
||||
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
|
||||
...optionalWarnings(record["warnings"]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { QueuedSessionMessage, SessionStatus } from "../api";
|
||||
import type { QueuedSessionMessage, SessionStatus, SessionWarning } from "../api";
|
||||
import type { ChatLine } from "./shared";
|
||||
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
|
||||
|
||||
@@ -68,6 +68,73 @@ describe("ChatView queued-message clear action", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView session warnings banner", () => {
|
||||
it("renders one severity-tagged row per warning with optional path and source", () => {
|
||||
const view = new ChatView();
|
||||
view.status = warningStatus([
|
||||
{ severity: "error", message: "skill failed to load", source: "skill", path: "/skills/a.md" },
|
||||
{ severity: "warning", message: "subscription auth is active" },
|
||||
{ severity: "info", message: "heads up", source: "runtime" },
|
||||
]);
|
||||
|
||||
const rendered = renderWarnings(view);
|
||||
if (rendered === null) throw new Error("expected a warnings banner");
|
||||
const markup = templateStaticMarkup(rendered);
|
||||
const values = collectStringValues(rendered);
|
||||
|
||||
expect(markup).toContain('class="session-warnings"');
|
||||
expect(markup).toContain('role="alert"');
|
||||
expect(values).toContain("skill failed to load");
|
||||
expect(values).toContain("/skills/a.md");
|
||||
expect(values).toContain("subscription auth is active");
|
||||
expect(values).toContain("heads up");
|
||||
expect(values).toContain("skill");
|
||||
const severityClasses = values.filter((value) => value.startsWith("session-warning "));
|
||||
expect(severityClasses).toEqual(["session-warning error", "session-warning warning", "session-warning info"]);
|
||||
});
|
||||
|
||||
it("renders nothing when there are no warnings", () => {
|
||||
const view = new ChatView();
|
||||
view.status = warningStatus([]);
|
||||
expect(renderWarnings(view)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when status is unset", () => {
|
||||
expect(renderWarnings(new ChatView())).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a dismiss control only for warnings carrying a dismiss capability", () => {
|
||||
const view = new ChatView();
|
||||
view.status = warningStatus([
|
||||
{ severity: "error", message: "skill failed to load", source: "skill" },
|
||||
{ severity: "warning", message: "subscription auth is active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
|
||||
]);
|
||||
|
||||
const rendered = renderWarnings(view);
|
||||
if (rendered === null) throw new Error("expected a warnings banner");
|
||||
const markup = templateStaticMarkup(rendered);
|
||||
// One dismiss button total: only the warning with a dismiss capability gets one.
|
||||
expect(markup.match(/session-warning-dismiss/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
// Direct handler extraction keeps this node-environment test focused on the
|
||||
// dismiss button wiring without a component-wide DOM shim.
|
||||
it("invokes onDismissWarning with the warning's dismiss id", () => {
|
||||
const view = new ChatView();
|
||||
const onDismissWarning = vi.fn();
|
||||
view.onDismissWarning = onDismissWarning;
|
||||
view.status = warningStatus([
|
||||
{ severity: "warning", message: "subscription auth is active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } },
|
||||
]);
|
||||
|
||||
const rendered = renderWarnings(view);
|
||||
if (rendered === null) throw new Error("expected a warnings banner");
|
||||
templateEventHandler(rendered, "session-warning-dismiss")(new Event("click"));
|
||||
|
||||
expect(onDismissWarning).toHaveBeenCalledExactlyOnceWith("anthropicExtraUsage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatMessageMetadataLabel", () => {
|
||||
it("uses one full date and model label without a model prefix", () => {
|
||||
const timestamp = "2026-07-10T19:15:30.000Z";
|
||||
@@ -293,6 +360,44 @@ function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
function collectStringValues(template: TemplateResult): string[] {
|
||||
const found: string[] = [];
|
||||
visit(template);
|
||||
return found;
|
||||
|
||||
function visit(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
found.push(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(value)) return;
|
||||
for (const child of templateValues(value)) visit(child);
|
||||
}
|
||||
}
|
||||
|
||||
type RenderWarnings = (this: ChatView) => TemplateResult | null;
|
||||
|
||||
function isRenderWarnings(value: unknown): value is RenderWarnings {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function renderWarnings(view: ChatView): TemplateResult | null {
|
||||
const method: unknown = Reflect.get(view, "renderWarnings");
|
||||
if (!isRenderWarnings(method)) throw new Error("ChatView.renderWarnings is not callable");
|
||||
return method.call(view);
|
||||
}
|
||||
|
||||
function warningStatus(warnings: SessionWarning[]): SessionStatus {
|
||||
return {
|
||||
...queuedStatus([]),
|
||||
...(warnings.length === 0 ? {} : { warnings }),
|
||||
};
|
||||
}
|
||||
|
||||
function queuedStatus(queuedMessages: QueuedSessionMessage[]): SessionStatus {
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { writeClipboardText } from "../clipboard";
|
||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
||||
import type { QueuedSessionMessage, SessionActivity, SessionStatus } from "../api";
|
||||
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
|
||||
import type { ChatLine, ChatPart } from "./shared";
|
||||
import { chatStyles } from "./shared";
|
||||
import "./ConversationMeter";
|
||||
@@ -16,6 +16,12 @@ import "./ToolExecutionView";
|
||||
|
||||
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
||||
|
||||
function warningSeverityIcon(severity: SessionWarningSeverity): string {
|
||||
if (severity === "error") return "⛔";
|
||||
if (severity === "info") return "ℹ️";
|
||||
return "⚠️";
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return clampNumber(value, 0, 100);
|
||||
}
|
||||
@@ -78,6 +84,7 @@ export class ChatView extends LitElement {
|
||||
@property({ attribute: false }) activity?: SessionActivity;
|
||||
@property({ type: Boolean }) canClearServerQueue = false;
|
||||
@property({ attribute: false }) onClearServerQueue?: () => void;
|
||||
@property({ attribute: false }) onDismissWarning?: (dismissId: string) => void;
|
||||
@property({ attribute: false }) onLoadMore?: () => void;
|
||||
@query(".chat") private chat?: HTMLDivElement;
|
||||
@query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
|
||||
@@ -208,6 +215,7 @@ export class ChatView extends LitElement {
|
||||
override render() {
|
||||
const groups = this.groupedMessages();
|
||||
return html`
|
||||
${this.renderWarnings()}
|
||||
<div class="chat-wrap">
|
||||
${this.renderConversationRail()}
|
||||
<div class="chat" @scroll=${() => { this.onScroll(); }} @wheel=${(event: WheelEvent) => { this.onWheel(event); }} @touchstart=${(event: TouchEvent) => { this.onTouchStart(event); }} @touchmove=${(event: TouchEvent) => { this.onTouchMove(event); }}>
|
||||
@@ -230,6 +238,39 @@ export class ChatView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWarnings() {
|
||||
const warnings = this.status?.warnings ?? [];
|
||||
if (warnings.length === 0) return null;
|
||||
return html`
|
||||
<aside class="session-warnings" role="alert" aria-live="polite">
|
||||
${warnings.map((warning) => {
|
||||
const dismiss = warning.dismiss;
|
||||
return html`
|
||||
<div class=${`session-warning ${warning.severity}`}>
|
||||
<div class="session-warning-head">
|
||||
<span class="session-warning-icon" aria-hidden="true">${warningSeverityIcon(warning.severity)}</span>
|
||||
${warning.source === undefined ? null : html`<span class="session-warning-source">${warning.source}</span>`}
|
||||
</div>
|
||||
<div class="session-warning-body">
|
||||
<p class="session-warning-message">${warning.message}</p>
|
||||
${warning.path === undefined ? null : html`<p class="session-warning-path">${warning.path}</p>`}
|
||||
</div>
|
||||
${dismiss === undefined ? null : html`
|
||||
<button
|
||||
type="button"
|
||||
class="session-warning-dismiss"
|
||||
title="Don't show this warning again"
|
||||
aria-label="Dismiss warning"
|
||||
@click=${() => { this.onDismissWarning?.(dismiss.id); }}
|
||||
>×</button>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderImageZoom() {
|
||||
return html`
|
||||
<dialog class="image-zoom" @click=${this.onImageZoomDialogClick} @close=${this.closeImageZoom} @cancel=${this.closeImageZoom}>
|
||||
|
||||
@@ -1874,6 +1874,10 @@ export class PiWebApp extends LitElement {
|
||||
void this.sessions.clearServerQueue();
|
||||
};
|
||||
|
||||
private readonly handleDismissWarning = (dismissId: string): void => {
|
||||
void this.sessions.dismissWarning(dismissId);
|
||||
};
|
||||
|
||||
private readonly handleSelectModel = (): void => {
|
||||
void this.openModelDialog();
|
||||
};
|
||||
@@ -1884,7 +1888,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private renderChatView(state: AppState, session: SessionInfo) {
|
||||
return html`
|
||||
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +273,19 @@ export const listStyles = css`
|
||||
export const chatStyles = css`
|
||||
:host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
|
||||
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||
.session-warnings { flex: 0 0 auto; display: grid; gap: 8px; max-height: 40%; overflow-y: auto; box-sizing: border-box; padding: 10px 16px; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg-overlay); }
|
||||
.session-warning { position: relative; display: grid; gap: 4px; box-sizing: border-box; padding: 10px 34px 10px 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); }
|
||||
.session-warning.error { border-color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 12%, var(--pi-surface)); }
|
||||
.session-warning.info { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
|
||||
.session-warning-head { display: flex; align-items: center; gap: 8px; min-height: 16px; }
|
||||
.session-warning-icon { flex: 0 0 auto; font-size: 14px; line-height: 1.4; }
|
||||
.session-warning-body { min-width: 0; display: grid; gap: 3px; }
|
||||
.session-warning-message { margin: 0; overflow-wrap: anywhere; }
|
||||
.session-warning-path { margin: 0; color: var(--pi-muted); font-size: 12px; font-family: var(--pi-mono, ui-monospace, monospace); overflow-wrap: anywhere; }
|
||||
.session-warning-source { color: var(--pi-muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.session-warning-dismiss { position: absolute; top: 6px; right: 6px; display: inline-grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); font: 15px/1 system-ui, sans-serif; cursor: pointer; }
|
||||
.session-warning-dismiss:hover, .session-warning-dismiss:focus-visible { color: var(--pi-text-bright); border-color: var(--pi-accent); background: var(--pi-bg-overlay); }
|
||||
.session-warning-dismiss:focus-visible { outline: 1px solid var(--pi-border); outline-offset: 2px; }
|
||||
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
|
||||
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
|
||||
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, FakeSocket, oldSession, sessionLookupId, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
|
||||
|
||||
function machine(id: string): NonNullable<AppState["selectedMachine"]> {
|
||||
return { id, name: id, kind: "remote", createdAt: "now", updatedAt: "now" };
|
||||
}
|
||||
|
||||
function warningStatus(sessionId: string): SessionStatus {
|
||||
return {
|
||||
...status(sessionId),
|
||||
warnings: [{ severity: "warning", message: "subscription active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionController warning dismissal", () => {
|
||||
it("dismisses via the API and applies the recomputed status", async () => {
|
||||
const withWarning = warningStatus(oldSession.id);
|
||||
const withoutWarning: SessionStatus = { ...status(oldSession.id), warnings: [] };
|
||||
const dismissCalls: { sessionId: string; dismissId: string; machineId: string }[] = [];
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedMachine: machine("remote-a"),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
status: withWarning,
|
||||
sessionStatuses: { [oldSession.id]: withWarning },
|
||||
};
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
dismissWarning: (session, dismissId, machineId) => {
|
||||
dismissCalls.push({ sessionId: sessionLookupId(session), dismissId, machineId: machineId ?? "local" });
|
||||
return Promise.resolve(withoutWarning);
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.dismissWarning("anthropicExtraUsage");
|
||||
|
||||
expect(dismissCalls).toEqual([{ sessionId: oldSession.id, dismissId: "anthropicExtraUsage", machineId: "remote-a" }]);
|
||||
expect(state.status).toEqual(withoutWarning);
|
||||
expect(state.sessionStatuses[oldSession.id]).toEqual(withoutWarning);
|
||||
});
|
||||
|
||||
it("reports dismissal failures through the application error state", async () => {
|
||||
const withWarning = warningStatus(oldSession.id);
|
||||
let state: AppState = {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
status: withWarning,
|
||||
sessionStatuses: { [oldSession.id]: withWarning },
|
||||
};
|
||||
const api: typeof defaultApi = { ...defaultApi, dismissWarning: () => Promise.reject(new Error("dismiss failed")) };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.dismissWarning("anthropicExtraUsage");
|
||||
|
||||
expect(state.error).toBe("Error: dismiss failed");
|
||||
expect(state.status).toBe(withWarning);
|
||||
});
|
||||
});
|
||||
@@ -741,6 +741,20 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async dismissWarning(dismissId: string) {
|
||||
const state = this.getState();
|
||||
const session = state.selectedSession;
|
||||
if (session === undefined || isClientPendingStartSessionInfo(session)) return;
|
||||
const machineId = selectedMachineId(state);
|
||||
const selectionSeq = this.selectionSeq;
|
||||
try {
|
||||
const status = await this.api.dismissWarning(session, dismissId, machineId);
|
||||
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.applyStatus(status);
|
||||
} catch (error) {
|
||||
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async stopActiveWork() {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
|
||||
Reference in New Issue
Block a user