Archived
feat: add collapsible session warnings
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Let users minimise session warnings into an accessible status-bar count, and replace warning emoji with SVG icons.
|
||||||
@@ -141,7 +141,29 @@ describe("ChatView session-warning dismiss wiring", () => {
|
|||||||
expect(onDismissWarning).toHaveBeenCalledExactlyOnceWith("anthropicExtraUsage");
|
expect(onDismissWarning).toHaveBeenCalledExactlyOnceWith("anthropicExtraUsage");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders nothing when there are no warnings", () => {
|
// Escape hatch: this verifies the collapse button's Lit callback wiring in
|
||||||
|
// the node test environment, anchored to its stable semantic class marker.
|
||||||
|
it("invokes onCollapseWarnings from the visible warning area", () => {
|
||||||
|
const view = withStatus(new ChatView(), warningStatus([
|
||||||
|
{ severity: "warning", message: "subscription auth is active" },
|
||||||
|
]));
|
||||||
|
const onCollapseWarnings = vi.fn();
|
||||||
|
view.onCollapseWarnings = onCollapseWarnings;
|
||||||
|
|
||||||
|
const rendered = renderWarnings(view);
|
||||||
|
if (rendered === null) throw new Error("expected a warnings banner");
|
||||||
|
templateEventHandlerAfterMarker(rendered, "session-warnings-collapse")(new Event("click"));
|
||||||
|
|
||||||
|
expect(onCollapseWarnings).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the warning area while presentation is collapsed or there are no warnings", () => {
|
||||||
|
const view = withStatus(new ChatView(), warningStatus([
|
||||||
|
{ severity: "warning", message: "subscription auth is active" },
|
||||||
|
]));
|
||||||
|
view.warningsVisible = false;
|
||||||
|
|
||||||
|
expect(renderWarnings(view)).toBeNull();
|
||||||
expect(renderWarnings(withStatus(new ChatView(), warningStatus([])))).toBeNull();
|
expect(renderWarnings(withStatus(new ChatView(), warningStatus([])))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html, svg } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import { ChatDisclosureController } from "../chatDisclosure";
|
import { ChatDisclosureController } from "../chatDisclosure";
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type SessionNotificationTarget,
|
type SessionNotificationTarget,
|
||||||
} from "../sessionNotifications";
|
} from "../sessionNotifications";
|
||||||
import type { ChatLine, ChatPart } from "./shared";
|
import type { ChatLine, ChatPart } from "./shared";
|
||||||
import { chatStyles } from "./shared";
|
import { chatStyles, renderSessionWarningIcon } from "./shared";
|
||||||
import "./ConversationMeter";
|
import "./ConversationMeter";
|
||||||
import "./FormattedText";
|
import "./FormattedText";
|
||||||
import "./ToolExecutionView";
|
import "./ToolExecutionView";
|
||||||
@@ -33,12 +33,6 @@ import "./ToolExecutionView";
|
|||||||
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" });
|
||||||
const notificationTimestampFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
|
const notificationTimestampFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
|
||||||
|
|
||||||
function warningSeverityIcon(severity: SessionWarningSeverity): string {
|
|
||||||
if (severity === "error") return "⛔";
|
|
||||||
if (severity === "info") return "ℹ️";
|
|
||||||
return "⚠️";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNotificationDisclosureIcon(collapsed: boolean) {
|
function renderNotificationDisclosureIcon(collapsed: boolean) {
|
||||||
return html`
|
return html`
|
||||||
<svg class=${`notification-icon notification-disclosure-icon${collapsed ? "" : " expanded"}`} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg class=${`notification-icon notification-disclosure-icon${collapsed ? "" : " expanded"}`} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
@@ -204,6 +198,8 @@ export class ChatView extends LitElement {
|
|||||||
@property({ attribute: false }) onDismissWarning?: (dismissId: string) => void;
|
@property({ attribute: false }) onDismissWarning?: (dismissId: string) => void;
|
||||||
@property({ attribute: false }) onDismissNotification?: (notificationId: string) => void;
|
@property({ attribute: false }) onDismissNotification?: (notificationId: string) => void;
|
||||||
@property({ attribute: false }) onDismissAllNotifications?: () => void;
|
@property({ attribute: false }) onDismissAllNotifications?: () => void;
|
||||||
|
@property({ type: Boolean }) warningsVisible = true;
|
||||||
|
@property({ attribute: false }) onCollapseWarnings?: () => void;
|
||||||
@property({ attribute: false }) onLoadMore?: () => void;
|
@property({ attribute: false }) onLoadMore?: () => void;
|
||||||
@query(".chat") private chat?: HTMLDivElement;
|
@query(".chat") private chat?: HTMLDivElement;
|
||||||
@query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
|
@query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
|
||||||
@@ -257,6 +253,9 @@ export class ChatView extends LitElement {
|
|||||||
private readonly handleClearServerQueue = (): void => {
|
private readonly handleClearServerQueue = (): void => {
|
||||||
this.onClearServerQueue?.();
|
this.onClearServerQueue?.();
|
||||||
};
|
};
|
||||||
|
private readonly handleCollapseWarnings = (): void => {
|
||||||
|
this.onCollapseWarnings?.();
|
||||||
|
};
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -513,15 +512,33 @@ export class ChatView extends LitElement {
|
|||||||
|
|
||||||
private renderWarnings() {
|
private renderWarnings() {
|
||||||
const rows = chatSessionWarningRows(this.status);
|
const rows = chatSessionWarningRows(this.status);
|
||||||
if (rows.length === 0) return null;
|
if (!this.warningsVisible || rows.length === 0) return null;
|
||||||
return html`
|
return html`
|
||||||
<aside class="session-warnings" role="alert" aria-live="polite">
|
<aside class="session-warnings" role="alert" aria-live="polite">
|
||||||
|
${this.onCollapseWarnings === undefined ? null : html`
|
||||||
|
<div class="session-warnings-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-warnings-collapse"
|
||||||
|
title="Minimise warnings"
|
||||||
|
aria-label="Minimise warnings"
|
||||||
|
@click=${this.handleCollapseWarnings}
|
||||||
|
>
|
||||||
|
${svg`
|
||||||
|
<svg class="session-warnings-collapse-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m18 15-6-6-6 6"></path>
|
||||||
|
</svg>
|
||||||
|
`}
|
||||||
|
<span>Minimise</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
${rows.map((row) => {
|
${rows.map((row) => {
|
||||||
const dismissId = row.dismissId;
|
const dismissId = row.dismissId;
|
||||||
return html`
|
return html`
|
||||||
<div class=${row.severityClass}>
|
<div class=${row.severityClass}>
|
||||||
<div class="session-warning-head">
|
<div class="session-warning-head">
|
||||||
<span class="session-warning-icon" aria-hidden="true">${warningSeverityIcon(row.severity)}</span>
|
${renderSessionWarningIcon(row.severity, "session-warning-icon")}
|
||||||
${row.source === undefined ? null : html`<span class="session-warning-source">${row.source}</span>`}
|
${row.source === undefined ? null : html`<span class="session-warning-source">${row.source}</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="session-warning-body">
|
<div class="session-warning-body">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { selectedMachineId } from "../controllers/types";
|
|||||||
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi";
|
||||||
import { selectedNotificationView } from "../sessionNotifications";
|
import { selectedNotificationView } from "../sessionNotifications";
|
||||||
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence";
|
||||||
|
import { collapseSessionWarnings, initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, restoreSessionWarnings } from "../sessionWarningVisibility";
|
||||||
import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket";
|
import { RealtimeSocket, type BrowserRealtimeEvent } from "../sessionSocket";
|
||||||
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
import type { PiWebPluginRegistration, PluginMachine, PluginPromptEditor, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspaceFiles, WorkspaceHost, WorkspaceLabelContext, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
|
||||||
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
|
||||||
@@ -216,6 +217,7 @@ export class PiWebApp extends LitElement {
|
|||||||
@state() private settingsSection: SettingsSection | undefined = readSettingsSection();
|
@state() private settingsSection: SettingsSection | undefined = readSettingsSection();
|
||||||
@state() private shortcutConfig: PiWebShortcutConfig = {};
|
@state() private shortcutConfig: PiWebShortcutConfig = {};
|
||||||
@state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
|
@state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined);
|
||||||
|
private sessionWarningVisibility = initialSessionWarningVisibilityState();
|
||||||
private readonly onPopState = () => void this.withChatScrollTransition(async () => {
|
private readonly onPopState = () => void this.withChatScrollTransition(async () => {
|
||||||
this.restoreSettingsRoute();
|
this.restoreSettingsRoute();
|
||||||
await this.restoreRoute(false);
|
await this.restoreRoute(false);
|
||||||
@@ -241,6 +243,15 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
protected override willUpdate(): void {
|
protected override willUpdate(): void {
|
||||||
this.toggleAttribute("pwa-display-mode", this.appShell.isPwaDisplayMode);
|
this.toggleAttribute("pwa-display-mode", this.appShell.isPwaDisplayMode);
|
||||||
|
this.syncSessionWarningVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSessionWarningVisibility(): void {
|
||||||
|
this.sessionWarningVisibility = reconcileSessionWarningVisibility(
|
||||||
|
this.sessionWarningVisibility,
|
||||||
|
this.state.selectedSession?.id,
|
||||||
|
this.state.status?.warnings,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
@@ -1913,6 +1924,20 @@ export class PiWebApp extends LitElement {
|
|||||||
void this.notifications.dismissAll();
|
void this.notifications.dismissAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly handleCollapseWarnings = (): void => {
|
||||||
|
const next = collapseSessionWarnings(this.sessionWarningVisibility);
|
||||||
|
if (next === this.sessionWarningVisibility) return;
|
||||||
|
this.sessionWarningVisibility = next;
|
||||||
|
this.requestUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly handleRestoreWarnings = (): void => {
|
||||||
|
const next = restoreSessionWarnings(this.sessionWarningVisibility);
|
||||||
|
if (next === this.sessionWarningVisibility) return;
|
||||||
|
this.sessionWarningVisibility = next;
|
||||||
|
this.requestUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
private readonly handleSelectModel = (): void => {
|
private readonly handleSelectModel = (): void => {
|
||||||
void this.openModelDialog();
|
void this.openModelDialog();
|
||||||
};
|
};
|
||||||
@@ -1923,7 +1948,16 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private renderChatView(state: AppState, session: SessionInfo) {
|
private renderChatView(state: AppState, session: SessionInfo) {
|
||||||
return html`
|
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} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .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} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onCollapseWarnings=${this.handleCollapseWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStatusBar(state: AppState) {
|
||||||
|
const collapsedWarningCount = this.sessionWarningVisibility.collapsed
|
||||||
|
? this.sessionWarningVisibility.warningCount
|
||||||
|
: 0;
|
||||||
|
return html`
|
||||||
|
<status-bar .status=${state.status} .collapsedWarningCount=${collapsedWarningCount} .onRestoreWarnings=${this.handleRestoreWarnings}></status-bar>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1987,7 +2021,7 @@ export class PiWebApp extends LitElement {
|
|||||||
${state.selectedSession ? html`
|
${state.selectedSession ? html`
|
||||||
${this.renderChatView(state, state.selectedSession)}
|
${this.renderChatView(state, state.selectedSession)}
|
||||||
<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=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></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=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor>
|
||||||
<status-bar .status=${state.status}></status-bar>
|
${this.renderStatusBar(state)}
|
||||||
${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.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}
|
${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}
|
||||||
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
|
${state.thinkingDialog !== undefined ? html`<command-picker title=${state.thinkingDialog.title} .options=${state.thinkingDialog.options} .selectedValue=${state.thinkingDialog.selectedValue} .onPick=${(value: string) => { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}></command-picker>` : null}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { TemplateResult } from "lit";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { SessionInfo, SessionStatus } from "../api";
|
||||||
|
import { initialAppState, type AppState } from "../appState";
|
||||||
|
// Template inspection is proportionate here because this test verifies only the
|
||||||
|
// sibling-component callback/property wiring in a node environment without DOM.
|
||||||
|
import { templateValueAfterMarker } from "../templateInspection.testSupport";
|
||||||
|
import { PiWebApp } from "./PiWebApp";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PiWebApp session-warning visibility wiring", () => {
|
||||||
|
it("collapses the existing warning area and restores it from the status bar", () => {
|
||||||
|
const app = createApp();
|
||||||
|
const state = stateWithWarnings();
|
||||||
|
setAppState(app, state);
|
||||||
|
syncWarningVisibility(app);
|
||||||
|
|
||||||
|
const visibleChat = renderChatView(app, state);
|
||||||
|
expect(templateValueAfterMarker(visibleChat, ".warningsVisible=")).toBe(true);
|
||||||
|
|
||||||
|
const collapse = templateCallbackAfterMarker(visibleChat, ".onCollapseWarnings=");
|
||||||
|
collapse();
|
||||||
|
|
||||||
|
const collapsedChat = renderChatView(app, state);
|
||||||
|
const collapsedStatusBar = renderStatusBar(app, state);
|
||||||
|
expect(templateValueAfterMarker(collapsedChat, ".warningsVisible=")).toBe(false);
|
||||||
|
expect(templateValueAfterMarker(collapsedStatusBar, ".collapsedWarningCount=")).toBe(2);
|
||||||
|
|
||||||
|
const restore = templateCallbackAfterMarker(collapsedStatusBar, ".onRestoreWarnings=");
|
||||||
|
restore();
|
||||||
|
|
||||||
|
expect(templateValueAfterMarker(renderChatView(app, state), ".warningsVisible=")).toBe(true);
|
||||||
|
expect(templateValueAfterMarker(renderStatusBar(app, state), ".collapsedWarningCount=")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type RenderChatView = (this: PiWebApp, state: AppState, session: SessionInfo) => TemplateResult;
|
||||||
|
type RenderStatusBar = (this: PiWebApp, state: AppState) => TemplateResult;
|
||||||
|
type SyncWarningVisibility = (this: PiWebApp) => void;
|
||||||
|
type WarningVisibilityCallback = () => void;
|
||||||
|
|
||||||
|
function createApp(): PiWebApp {
|
||||||
|
const storage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => undefined,
|
||||||
|
removeItem: () => undefined,
|
||||||
|
};
|
||||||
|
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
|
||||||
|
return new PiWebApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWithWarnings(): AppState {
|
||||||
|
const selectedSession: SessionInfo = {
|
||||||
|
id: "session-1",
|
||||||
|
cwd: "/repo",
|
||||||
|
path: "/repo/session-1.jsonl",
|
||||||
|
created: "2026-07-14T00:00:00.000Z",
|
||||||
|
modified: "2026-07-14T00:00:00.000Z",
|
||||||
|
messageCount: 1,
|
||||||
|
firstMessage: "hello",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...initialAppState(),
|
||||||
|
selectedSession,
|
||||||
|
status: warningStatus(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function warningStatus(): SessionStatus {
|
||||||
|
return {
|
||||||
|
sessionId: "session-1",
|
||||||
|
isStreaming: true,
|
||||||
|
isCompacting: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
|
queuedMessages: [],
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
cost: 0,
|
||||||
|
warnings: [
|
||||||
|
{ severity: "warning", message: "subscription auth is active" },
|
||||||
|
{ severity: "error", message: "skill failed to load" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAppState(app: PiWebApp, state: AppState): void {
|
||||||
|
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncWarningVisibility(app: PiWebApp): void {
|
||||||
|
const method: unknown = Reflect.get(app, "syncSessionWarningVisibility");
|
||||||
|
if (!isSyncWarningVisibility(method)) throw new Error("PiWebApp.syncSessionWarningVisibility is not callable");
|
||||||
|
method.call(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChatView(app: PiWebApp, state: AppState): TemplateResult {
|
||||||
|
const method: unknown = Reflect.get(app, "renderChatView");
|
||||||
|
if (!isRenderChatView(method)) throw new Error("PiWebApp.renderChatView is not callable");
|
||||||
|
const session = state.selectedSession;
|
||||||
|
if (session === undefined) throw new Error("Expected a selected session");
|
||||||
|
return method.call(app, state, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusBar(app: PiWebApp, state: AppState): TemplateResult {
|
||||||
|
const method: unknown = Reflect.get(app, "renderStatusBar");
|
||||||
|
if (!isRenderStatusBar(method)) throw new Error("PiWebApp.renderStatusBar is not callable");
|
||||||
|
return method.call(app, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateCallbackAfterMarker(template: TemplateResult, marker: string): WarningVisibilityCallback {
|
||||||
|
const value = templateValueAfterMarker(template, marker);
|
||||||
|
if (!isWarningVisibilityCallback(value)) throw new Error(`Expected callback after ${marker}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderChatView(value: unknown): value is RenderChatView {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderStatusBar(value: unknown): value is RenderStatusBar {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSyncWarningVisibility(value: unknown): value is SyncWarningVisibility {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWarningVisibilityCallback(value: unknown): value is WarningVisibilityCallback {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { TemplateResult } from "lit";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { SessionStatus } from "../api";
|
||||||
|
import { templateEventHandlerAfterMarker } from "../templateInspection.testSupport";
|
||||||
|
import { StatusBar, statusBarWarningControlContent } from "./StatusBar";
|
||||||
|
|
||||||
|
describe("statusBarWarningControlContent", () => {
|
||||||
|
it("provides only the visible numeric count while keeping a descriptive accessible label", () => {
|
||||||
|
expect(statusBarWarningControlContent(1)).toEqual({
|
||||||
|
countText: "1",
|
||||||
|
accessibleLabel: "Show 1 warning in the warning area",
|
||||||
|
});
|
||||||
|
expect(statusBarWarningControlContent(3)).toEqual({
|
||||||
|
countText: "3",
|
||||||
|
accessibleLabel: "Show 3 warnings in the warning area",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the control content when there are no collapsed warnings", () => {
|
||||||
|
expect(statusBarWarningControlContent(0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StatusBar warning restore wiring", () => {
|
||||||
|
// Escape hatch: this specifically verifies the compact status-bar button's
|
||||||
|
// Lit callback wiring in the node environment, anchored to its semantic class.
|
||||||
|
it("invokes onRestoreWarnings when the warning-count control is activated", () => {
|
||||||
|
const statusBar = new StatusBar();
|
||||||
|
const onRestoreWarnings = vi.fn();
|
||||||
|
statusBar.status = status();
|
||||||
|
statusBar.collapsedWarningCount = 2;
|
||||||
|
statusBar.onRestoreWarnings = onRestoreWarnings;
|
||||||
|
|
||||||
|
templateEventHandlerAfterMarker(renderStatusBar(statusBar), "warning-restore")(new Event("click"));
|
||||||
|
|
||||||
|
expect(onRestoreWarnings).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type RenderStatusBar = (this: StatusBar) => TemplateResult;
|
||||||
|
|
||||||
|
function renderStatusBar(statusBar: StatusBar): TemplateResult {
|
||||||
|
const method: unknown = Reflect.get(statusBar, "render");
|
||||||
|
if (!isRenderStatusBar(method)) throw new Error("StatusBar.render is not callable");
|
||||||
|
return method.call(statusBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderStatusBar(value: unknown): value is RenderStatusBar {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): SessionStatus {
|
||||||
|
return {
|
||||||
|
sessionId: "session-1",
|
||||||
|
isStreaming: true,
|
||||||
|
isCompacting: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
|
queuedMessages: [],
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
cost: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,11 +2,31 @@ import { LitElement, html } from "lit";
|
|||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import type { SessionStatus } from "../api";
|
import type { SessionStatus } from "../api";
|
||||||
import { formatCost, formatTokenCount } from "../utils/format";
|
import { formatCost, formatTokenCount } from "../utils/format";
|
||||||
import { statusBarStyles } from "./shared";
|
import { renderSessionWarningIcon, statusBarStyles } from "./shared";
|
||||||
|
|
||||||
|
export interface StatusBarWarningControlContent {
|
||||||
|
countText: string;
|
||||||
|
accessibleLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusBarWarningControlContent(count: number): StatusBarWarningControlContent | undefined {
|
||||||
|
if (!Number.isInteger(count) || count <= 0) return undefined;
|
||||||
|
const warningText = `${String(count)} ${count === 1 ? "warning" : "warnings"}`;
|
||||||
|
return {
|
||||||
|
countText: String(count),
|
||||||
|
accessibleLabel: `Show ${warningText} in the warning area`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@customElement("status-bar")
|
@customElement("status-bar")
|
||||||
export class StatusBar extends LitElement {
|
export class StatusBar extends LitElement {
|
||||||
@property({ attribute: false }) status?: SessionStatus;
|
@property({ attribute: false }) status?: SessionStatus;
|
||||||
|
@property({ type: Number }) collapsedWarningCount = 0;
|
||||||
|
@property({ attribute: false }) onRestoreWarnings?: () => void;
|
||||||
|
|
||||||
|
private readonly handleRestoreWarnings = (): void => {
|
||||||
|
this.onRestoreWarnings?.();
|
||||||
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const status = this.status;
|
const status = this.status;
|
||||||
@@ -18,8 +38,21 @@ export class StatusBar extends LitElement {
|
|||||||
: `${context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
|
: `${context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}`
|
||||||
: "context unknown";
|
: "context unknown";
|
||||||
const tokens = status.tokens;
|
const tokens = status.tokens;
|
||||||
|
const warningControl = statusBarWarningControlContent(this.collapsedWarningCount);
|
||||||
return html`
|
return html`
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
|
${warningControl === undefined || this.onRestoreWarnings === undefined ? null : html`
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="warning-restore"
|
||||||
|
title=${warningControl.accessibleLabel}
|
||||||
|
aria-label=${warningControl.accessibleLabel}
|
||||||
|
@click=${this.handleRestoreWarnings}
|
||||||
|
>
|
||||||
|
${renderSessionWarningIcon("warning", "warning-restore-icon")}
|
||||||
|
<span>${warningControl.countText}</span>
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
<span>↑${formatTokenCount(tokens.input)}</span>
|
<span>↑${formatTokenCount(tokens.input)}</span>
|
||||||
<span>↓${formatTokenCount(tokens.output)}</span>
|
<span>↓${formatTokenCount(tokens.output)}</span>
|
||||||
<span class="context">${contextText}</span>
|
<span class="context">${contextText}</span>
|
||||||
|
|||||||
@@ -1,4 +1,33 @@
|
|||||||
import { css } from "lit";
|
import { css, svg, type TemplateResult } from "lit";
|
||||||
|
import type { SessionWarningSeverity } from "../api";
|
||||||
|
|
||||||
|
export function renderSessionWarningIcon(severity: SessionWarningSeverity, className: string): TemplateResult {
|
||||||
|
if (severity === "error") {
|
||||||
|
return svg`
|
||||||
|
<svg class=${className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="m15 9-6 6"></path>
|
||||||
|
<path d="m9 9 6 6"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (severity === "info") {
|
||||||
|
return svg`
|
||||||
|
<svg class=${className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<path d="M12 11v5"></path>
|
||||||
|
<path d="M12 8h.01"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return svg`
|
||||||
|
<svg class=${className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M10.3 3.7 2.2 18a2 2 0 0 0 1.7 3h16.2a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z"></path>
|
||||||
|
<path d="M12 9v4"></path>
|
||||||
|
<path d="M12 17h.01"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolPreview {
|
export interface ToolPreview {
|
||||||
diff?: string;
|
diff?: string;
|
||||||
@@ -276,11 +305,16 @@ export const chatStyles = css`
|
|||||||
.top-notices { box-sizing: border-box; flex: 0 0 auto; max-height: 40%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg-overlay); }
|
.top-notices { box-sizing: border-box; flex: 0 0 auto; max-height: 40%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg-overlay); }
|
||||||
.session-warnings { flex: 0 1 auto; display: grid; gap: 8px; max-height: 50%; min-height: 0; overflow-y: auto; box-sizing: border-box; padding: 10px 16px; border-bottom: 1px solid var(--pi-border-muted); }
|
.session-warnings { flex: 0 1 auto; display: grid; gap: 8px; max-height: 50%; min-height: 0; overflow-y: auto; box-sizing: border-box; padding: 10px 16px; border-bottom: 1px solid var(--pi-border-muted); }
|
||||||
.session-warnings:only-child { flex: 1 1 auto; max-height: 100%; border-bottom: 0; }
|
.session-warnings:only-child { flex: 1 1 auto; max-height: 100%; border-bottom: 0; }
|
||||||
|
.session-warnings-controls { display: flex; justify-content: flex-end; }
|
||||||
|
.session-warnings-collapse { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 4px 7px; font: 12px system-ui, sans-serif; cursor: pointer; }
|
||||||
|
.session-warnings-collapse:hover, .session-warnings-collapse:focus-visible { color: var(--pi-text-bright); border-color: var(--pi-accent); background: var(--pi-bg-overlay); }
|
||||||
|
.session-warnings-collapse:focus-visible { outline: 1px solid var(--pi-border); outline-offset: 2px; }
|
||||||
|
.session-warnings-collapse-icon { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
||||||
.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 { 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.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.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-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-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
.session-warning-body { min-width: 0; display: grid; gap: 3px; }
|
.session-warning-body { min-width: 0; display: grid; gap: 3px; }
|
||||||
.session-warning-message { margin: 0; overflow-wrap: anywhere; }
|
.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-path { margin: 0; color: var(--pi-muted); font-size: 12px; font-family: var(--pi-mono, ui-monospace, monospace); overflow-wrap: anywhere; }
|
||||||
@@ -457,6 +491,9 @@ export const statusBarStyles = css`
|
|||||||
:host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; }
|
:host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; }
|
||||||
.bar { display: flex; justify-content: flex-end; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; }
|
.bar { display: flex; justify-content: flex-end; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; }
|
||||||
span { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
span { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.warning-restore { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; margin-right: auto; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; line-height: 1; white-space: nowrap; cursor: pointer; }
|
||||||
|
.warning-restore:focus-visible { outline: 1px solid currentColor; outline-offset: 2px; }
|
||||||
|
.warning-restore-icon { flex: 0 0 auto; width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
.activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); }
|
.activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); }
|
||||||
.activity.active { color: var(--pi-success); }
|
.activity.active { color: var(--pi-success); }
|
||||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SessionWarning } from "./api";
|
||||||
|
import {
|
||||||
|
collapseSessionWarnings,
|
||||||
|
initialSessionWarningVisibilityState,
|
||||||
|
reconcileSessionWarningVisibility,
|
||||||
|
restoreSessionWarnings,
|
||||||
|
sessionWarningSetSignature,
|
||||||
|
} from "./sessionWarningVisibility";
|
||||||
|
|
||||||
|
const subscriptionWarning: SessionWarning = { severity: "warning", message: "subscription auth is active", source: "anthropic", dismiss: { id: "anthropicExtraUsage" } };
|
||||||
|
const skillWarning: SessionWarning = { severity: "error", message: "skill failed to load", source: "skill", path: "/skills/a.md" };
|
||||||
|
const warnings: SessionWarning[] = [subscriptionWarning, skillWarning];
|
||||||
|
|
||||||
|
describe("sessionWarningSetSignature", () => {
|
||||||
|
it("identifies an equivalent warning set across object replacement and ordering", () => {
|
||||||
|
const replacement = [
|
||||||
|
{ ...skillWarning },
|
||||||
|
{ ...subscriptionWarning, dismiss: { id: "anthropicExtraUsage" } },
|
||||||
|
] satisfies SessionWarning[];
|
||||||
|
|
||||||
|
expect(sessionWarningSetSignature(replacement)).toBe(sessionWarningSetSignature(warnings));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when warning presentation or dismissal identity changes", () => {
|
||||||
|
const original = sessionWarningSetSignature(warnings);
|
||||||
|
const changes: SessionWarning[][] = [
|
||||||
|
[{ ...subscriptionWarning, severity: "error" }, skillWarning],
|
||||||
|
[{ ...subscriptionWarning, message: "subscription auth changed" }, skillWarning],
|
||||||
|
[{ ...subscriptionWarning, source: "runtime" }, skillWarning],
|
||||||
|
[subscriptionWarning, { ...skillWarning, path: "/skills/b.md" }],
|
||||||
|
[{ ...subscriptionWarning, dismiss: { id: "different" } }, skillWarning],
|
||||||
|
[...warnings, { severity: "info", message: "heads up" }],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const changed of changes) expect(sessionWarningSetSignature(changed)).not.toBe(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session warning visibility transitions", () => {
|
||||||
|
it("keeps an equivalent warning set collapsed across routine status replacement", () => {
|
||||||
|
const visible = reconcileSessionWarningVisibility(initialSessionWarningVisibilityState(), "session-1", warnings);
|
||||||
|
const collapsed = collapseSessionWarnings(visible);
|
||||||
|
const replacement = warnings.map((warning) => ({ ...warning }));
|
||||||
|
|
||||||
|
expect(reconcileSessionWarningVisibility(collapsed, "session-1", replacement)).toBe(collapsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens for changed warnings and when the same warnings return after clearing", () => {
|
||||||
|
const visible = reconcileSessionWarningVisibility(initialSessionWarningVisibilityState(), "session-1", warnings);
|
||||||
|
const collapsed = collapseSessionWarnings(visible);
|
||||||
|
const changed = reconcileSessionWarningVisibility(collapsed, "session-1", [{ ...subscriptionWarning, message: "changed" }, skillWarning]);
|
||||||
|
const cleared = reconcileSessionWarningVisibility(collapsed, "session-1", []);
|
||||||
|
const returned = reconcileSessionWarningVisibility(cleared, "session-1", warnings);
|
||||||
|
|
||||||
|
expect(changed.collapsed).toBe(false);
|
||||||
|
expect(cleared.collapsed).toBe(false);
|
||||||
|
expect(returned.collapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens when session selection changes even if the warning set is equal", () => {
|
||||||
|
const visible = reconcileSessionWarningVisibility(initialSessionWarningVisibilityState(), "session-1", warnings);
|
||||||
|
const collapsed = collapseSessionWarnings(visible);
|
||||||
|
|
||||||
|
expect(reconcileSessionWarningVisibility(collapsed, "session-2", warnings).collapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only collapses a non-empty warning set and restores it explicitly", () => {
|
||||||
|
const empty = initialSessionWarningVisibilityState();
|
||||||
|
const visible = reconcileSessionWarningVisibility(empty, "session-1", warnings);
|
||||||
|
const collapsed = collapseSessionWarnings(visible);
|
||||||
|
|
||||||
|
expect(collapseSessionWarnings(empty)).toBe(empty);
|
||||||
|
expect(collapsed.collapsed).toBe(true);
|
||||||
|
expect(restoreSessionWarnings(collapsed)).toEqual({ ...collapsed, collapsed: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { SessionWarning } from "./api";
|
||||||
|
|
||||||
|
export interface SessionWarningVisibilityState {
|
||||||
|
sessionId: string | undefined;
|
||||||
|
warningSetSignature: string;
|
||||||
|
warningCount: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialSessionWarningVisibilityState(): SessionWarningVisibilityState {
|
||||||
|
return {
|
||||||
|
sessionId: undefined,
|
||||||
|
warningSetSignature: sessionWarningSetSignature(undefined),
|
||||||
|
warningCount: 0,
|
||||||
|
collapsed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable, order-independent identity for the complete live warning set. */
|
||||||
|
export function sessionWarningSetSignature(warnings: readonly SessionWarning[] | undefined): string {
|
||||||
|
const warningIdentities = (warnings ?? [])
|
||||||
|
.map((warning) => JSON.stringify([
|
||||||
|
warning.severity,
|
||||||
|
warning.message,
|
||||||
|
warning.source,
|
||||||
|
warning.path,
|
||||||
|
warning.dismiss?.id,
|
||||||
|
]))
|
||||||
|
.sort();
|
||||||
|
return JSON.stringify(warningIdentities);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve collapse only while both the selected session and warning set are unchanged. */
|
||||||
|
export function reconcileSessionWarningVisibility(
|
||||||
|
current: SessionWarningVisibilityState,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
warnings: readonly SessionWarning[] | undefined,
|
||||||
|
): SessionWarningVisibilityState {
|
||||||
|
const warningSetSignature = sessionWarningSetSignature(warnings);
|
||||||
|
if (current.sessionId === sessionId && current.warningSetSignature === warningSetSignature) return current;
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
warningSetSignature,
|
||||||
|
warningCount: warnings?.length ?? 0,
|
||||||
|
collapsed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collapseSessionWarnings(current: SessionWarningVisibilityState): SessionWarningVisibilityState {
|
||||||
|
if (current.collapsed || current.warningCount === 0) return current;
|
||||||
|
return { ...current, collapsed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreSessionWarnings(current: SessionWarningVisibilityState): SessionWarningVisibilityState {
|
||||||
|
if (!current.collapsed) return current;
|
||||||
|
return { ...current, collapsed: false };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user