diff --git a/.changeset/quiet-session-warnings.md b/.changeset/quiet-session-warnings.md new file mode 100644 index 0000000..f64ae07 --- /dev/null +++ b/.changeset/quiet-session-warnings.md @@ -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. diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index d62340d..69320e7 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -141,7 +141,29 @@ describe("ChatView session-warning dismiss wiring", () => { 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(); }); }); diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 69b9724..c68507a 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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 { repeat } from "lit/directives/repeat.js"; import { ChatDisclosureController } from "../chatDisclosure"; @@ -25,7 +25,7 @@ import { type SessionNotificationTarget, } from "../sessionNotifications"; import type { ChatLine, ChatPart } from "./shared"; -import { chatStyles } from "./shared"; +import { chatStyles, renderSessionWarningIcon } from "./shared"; import "./ConversationMeter"; import "./FormattedText"; import "./ToolExecutionView"; @@ -33,12 +33,6 @@ import "./ToolExecutionView"; const messageTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }); 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) { return html`
+ +
+ `} ${rows.map((row) => { const dismissId = row.dismissId; return html`
- + ${renderSessionWarningIcon(row.severity, "session-warning-icon")} ${row.source === undefined ? null : html`${row.source}`}
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index a595dcf..b94f781 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -25,6 +25,7 @@ import { selectedMachineId } from "../controllers/types"; import { sessionCleanupRequestKey, sessionCleanupUnavailableMessage } from "../sessionCleanupUi"; import { selectedNotificationView } from "../sessionNotifications"; import { hasAuthoritativeSessionPersistence as runtimeHasAuthoritativeSessionPersistence } from "../sessionPersistence"; +import { collapseSessionWarnings, initialSessionWarningVisibilityState, reconcileSessionWarningVisibility, restoreSessionWarnings } from "../sessionWarningVisibility"; 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 { 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 shortcutConfig: PiWebShortcutConfig = {}; @state() private workspaceUploadDefaultFolder = effectiveWorkspaceUploadFolder(undefined); + private sessionWarningVisibility = initialSessionWarningVisibilityState(); private readonly onPopState = () => void this.withChatScrollTransition(async () => { this.restoreSettingsRoute(); await this.restoreRoute(false); @@ -241,6 +243,15 @@ export class PiWebApp extends LitElement { protected override willUpdate(): void { 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 { @@ -1913,6 +1924,20 @@ export class PiWebApp extends LitElement { 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 => { void this.openModelDialog(); }; @@ -1923,7 +1948,16 @@ export class PiWebApp extends LitElement { private renderChatView(state: AppState, session: SessionInfo) { return html` - 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())}> + 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())}> + `; + } + + private renderStatusBar(state: AppState) { + const collapsedWarningCount = this.sessionWarningVisibility.collapsed + ? this.sessionWarningVisibility.warningCount + : 0; + return html` + `; } @@ -1987,7 +2021,7 @@ export class PiWebApp extends LitElement { ${state.selectedSession ? html` ${this.renderChatView(state, state.selectedSession)} 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}> - + ${this.renderStatusBar(state)} ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PiWebApp.warningVisibility.test.ts b/src/client/src/components/PiWebApp.warningVisibility.test.ts new file mode 100644 index 0000000..7670872 --- /dev/null +++ b/src/client/src/components/PiWebApp.warningVisibility.test.ts @@ -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"; +} diff --git a/src/client/src/components/StatusBar.test.ts b/src/client/src/components/StatusBar.test.ts new file mode 100644 index 0000000..010c999 --- /dev/null +++ b/src/client/src/components/StatusBar.test.ts @@ -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, + }; +} diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts index 2fc2afb..7cd7838 100644 --- a/src/client/src/components/StatusBar.ts +++ b/src/client/src/components/StatusBar.ts @@ -2,11 +2,31 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import type { SessionStatus } from "../api"; 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") export class StatusBar extends LitElement { @property({ attribute: false }) status?: SessionStatus; + @property({ type: Number }) collapsedWarningCount = 0; + @property({ attribute: false }) onRestoreWarnings?: () => void; + + private readonly handleRestoreWarnings = (): void => { + this.onRestoreWarnings?.(); + }; override render() { const status = this.status; @@ -18,8 +38,21 @@ export class StatusBar extends LitElement { : `${context.percent.toFixed(1)}%/${formatTokenCount(context.contextWindow)}` : "context unknown"; const tokens = status.tokens; + const warningControl = statusBarWarningControlContent(this.collapsedWarningCount); return html`
+ ${warningControl === undefined || this.onRestoreWarnings === undefined ? null : html` + + `} ↑${formatTokenCount(tokens.input)} ↓${formatTokenCount(tokens.output)} ${contextText} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index a76c9b4..a27b8f3 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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` + + `; + } + if (severity === "info") { + return svg` + + `; + } + return svg` + + `; +} export interface ToolPreview { 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); } .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-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.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-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-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; } @@ -457,6 +491,9 @@ export const statusBarStyles = css` :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; } 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.active { color: var(--pi-success); } .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; } diff --git a/src/client/src/sessionWarningVisibility.test.ts b/src/client/src/sessionWarningVisibility.test.ts new file mode 100644 index 0000000..8673bcc --- /dev/null +++ b/src/client/src/sessionWarningVisibility.test.ts @@ -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 }); + }); +}); diff --git a/src/client/src/sessionWarningVisibility.ts b/src/client/src/sessionWarningVisibility.ts new file mode 100644 index 0000000..c31a097 --- /dev/null +++ b/src/client/src/sessionWarningVisibility.ts @@ -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 }; +}