Compare commits

...
Author SHA1 Message Date
Aidan Timson 63d1d1b9b8 Drop hass from error-log-card (#55062) 2026-10-19 14:13:25 +02:00
Aidan Timsonandcopilot-swe-agent[bot] bab1485473 Hide skipped updates in the Marketplace by default (#55061)
* Hide skipped updates in the Marketplace by default

* Drop the show skipped updates filter

* Narrow Marketplace skipped update subscriptions

* Work out skipped updates after their states are selected

* Keep the Marketplace table when the same updates are skipped

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-19 14:12:09 +02:00
0x696f6fandClaude Opus 5.5 363a1861e6 Fix view tab ignored after navigating to a hidden view (#54613)
* Fix view tab ignored after navigating to a hidden view

Web Awesome does not activate disabled tabs, so when the dashboard marks
the disabled tab of a hidden view active, the tab group keeps tracking the
previous tab and ignores clicks or Enter/Space on it. Show the picked tab
ourselves when the group ignored it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Drive the view tab group through its active property

Set the tab group's active property from the current view instead of only
marking the tabs, so the group never keeps a stale active tab after
navigating to a hidden view. Also show the tab of a hidden view while it is
the current view. This replaces the fallback in ha-tab-group, as suggested
in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 12:11:37 +00:00
Maarten Lakerveld 38ac2a01ec Do not start timers or keep subscriptions after an element is removed (#55056) 2026-10-19 10:31:17 +01:00
charishma2k06 7edd0f1c38 Fix entity pictures in automation targets (#54925) 2026-10-19 11:26:31 +02:00
CRYPTONIKAV 313ec9440e Gate welcome banner and dismiss save on loaded config (#54825)
* fix(home): gate welcome banner and dismiss save on loaded config

* chore: retrigger status evaluation

* fix: optional config instead of loaded flag, typed fixtures
2026-10-19 09:14:38 +00:00
5f0fb8ab23 Merge final assistant response into streamed chat text instead of replacing it (#54311)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 11:09:38 +02:00
24 changed files with 943 additions and 227 deletions

No files matched your search

+9 -3
View File
@@ -5,14 +5,20 @@ export const subscribePollingCollection = (
updateData: (hass: HomeAssistant) => void,
interval: number
) => {
let timeout;
let timeout: number | undefined;
let cancelled = false;
const fetchData = async () => {
try {
await updateData(hass);
} finally {
timeout = setTimeout(() => fetchData(), interval);
if (!cancelled) {
timeout = window.setTimeout(() => fetchData(), interval);
}
}
};
fetchData();
return () => clearTimeout(timeout);
return () => {
cancelled = true;
clearTimeout(timeout);
};
};
+16 -131
View File
@@ -15,14 +15,16 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
createAssistMessageProcessor,
type AssistMessage,
type AssistMessageProcessor,
} from "../data/assist_chat";
import {
runAssistPipeline,
type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import type { ChatLogToolResult } from "../data/chat_log";
import {
configContext,
connectionContext,
@@ -52,22 +54,6 @@ import "./ha-markdown";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
interface AssistMessage {
who: string;
text: string;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
string,
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: ChatLogToolResult;
}
>;
error?: boolean;
}
export const initialPromptToSubmit = (
prompt: string | undefined,
submit: boolean
@@ -653,6 +639,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
hassMessageProcesser.addMessage();
} else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
}
} else if (event.type === "run-end") {
this._stt_binary_handler_id = undefined;
unsub();
@@ -744,6 +733,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
(event) => {
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
}
}
if (event.type === "intent-end") {
unsub();
@@ -770,118 +762,11 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
}
}
private _createAddHassMessageProcessor() {
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (
progress.hassMessage.text === "…" &&
!progress.hassMessage.thinking &&
(!progress.hassMessage.tool_calls ||
Object.keys(progress.hassMessage.tool_calls).length === 0)
) {
return;
}
if (progress.hassMessage.text?.endsWith("…")) {
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
}
progress.hassMessage = {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
};
this._addMessage(progress.hassMessage);
};
const isAssistantDelta = (
_delta: any
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResult = (
_delta: any
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
},
addMessage: () => {
this._addMessage(progress.hassMessage);
},
setError: (error: string) => {
progressToNextMessage();
progress.hassMessage.text = error;
progress.hassMessage.error = true;
this.requestUpdate("_conversation");
},
processEvent: (event: PipelineRunEvent) => {
if (event.type === "intent-progress" && event.data.chat_log_delta) {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
if (progress.hassMessage.text.endsWith("…")) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
} else {
progress.hassMessage.text += delta.content + "…";
}
}
if (delta.thinking_content) {
progress.hassMessage.thinking += delta.thinking_content;
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
}
}
this.requestUpdate("_conversation");
} else if (isToolResult(delta)) {
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
progress.hassMessage.tool_calls[delta.tool_call_id].result =
delta.result;
this.requestUpdate("_conversation");
}
}
} else if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
progress.continueConversation =
event.data.intent_output.continue_conversation;
const response =
event.data.intent_output.response.speech.plain?.speech;
if (!response) {
return;
}
if (event.data.intent_output.response.response_type === "error") {
progress.setError(response);
} else {
progress.hassMessage.text = response;
this.requestUpdate("_conversation");
}
}
},
};
return progress;
private _createAddHassMessageProcessor(): AssistMessageProcessor {
return createAssistMessageProcessor({
addMessage: (message) => this._addMessage(message),
requestUpdate: () => this.requestUpdate("_conversation"),
});
}
static get styles(): CSSResultGroup {
+209
View File
@@ -0,0 +1,209 @@
import type {
ConversationChatLogAssistantDelta,
ConversationChatLogToolResultDelta,
PipelineRunEvent,
} from "./assist_pipeline";
import type { ChatLogToolResult } from "./chat_log";
export interface AssistMessage {
who: string;
text: string;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
string,
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: ChatLogToolResult;
}
>;
error?: boolean;
}
export interface AssistMessageProcessor {
readonly continueConversation: boolean;
readonly hassMessage: AssistMessage;
addMessage: () => void;
setError: (error: string) => void;
processEvent: (event: PipelineRunEvent) => void;
}
type ChatLogDelta =
| Partial<ConversationChatLogAssistantDelta>
| ConversationChatLogToolResultDelta;
const STREAMING_ELLIPSIS = "…";
const stripStreamingEllipsis = (text: string): string =>
text.endsWith(STREAMING_ELLIPSIS) ? text.slice(0, -1) : text;
const joinWithBreak = (previous: string, next: string): string => {
if (previous === "" || next === "") {
return previous + next;
}
if (
/\r?\n\r?\n$/.test(previous) ||
/^\r?\n\r?\n/.test(next) ||
(/\r?\n$/.test(previous) && /^\r?\n/.test(next))
) {
return previous + next;
}
return /\r?\n$/.test(previous) || /^\r?\n/.test(next)
? `${previous}\n${next}`
: `${previous}\n\n${next}`;
};
const newAssistantMessage = (): AssistMessage => ({
who: "hass",
text: STREAMING_ELLIPSIS,
thinking: "",
tool_calls: {},
error: false,
});
export const createAssistMessageProcessor = ({
addMessage,
requestUpdate,
}: {
addMessage: (message: AssistMessage) => void;
requestUpdate: () => void;
}): AssistMessageProcessor => {
let currentDeltaRole = "";
// The role and the content of a new message can arrive in separate deltas.
let pendingMessageBoundary = false;
// Once finalized, a trailing ellipsis is part of the reply and is kept.
let streamingMarker = true;
let latestSegment = "";
let continueConversation = false;
let hassMessage: AssistMessage = newAssistantMessage();
const isMessageEmpty = (message: AssistMessage) =>
streamingMarker &&
message.text === STREAMING_ELLIPSIS &&
!message.thinking &&
Object.keys(message.tool_calls).length === 0;
const finalizeStreamingText = () => {
if (streamingMarker) {
hassMessage.text = stripStreamingEllipsis(hassMessage.text);
}
streamingMarker = false;
};
const progressToNextMessage = () => {
if (isMessageEmpty(hassMessage)) {
return;
}
finalizeStreamingText();
hassMessage = newAssistantMessage();
streamingMarker = true;
latestSegment = "";
addMessage(hassMessage);
};
const isAssistantDelta = (
_delta: ChatLogDelta
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResultDelta = (
_delta: ChatLogDelta
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
// The response speech only contains the final assistant message, so merge
// it into the streamed text instead of replacing it.
const applyFinalResponse = (response: string) => {
finalizeStreamingText();
const streamed = hassMessage.text;
if (streamed === "") {
hassMessage.text = response;
} else if (latestSegment.trim() !== response.trim()) {
hassMessage.text = joinWithBreak(streamed, response);
}
};
const setError = (error: string) => {
progressToNextMessage();
hassMessage.text = error;
hassMessage.error = true;
streamingMarker = false;
requestUpdate();
};
const processEvent = (event: PipelineRunEvent) => {
if (event.type === "intent-progress" && event.data.chat_log_delta) {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
currentDeltaRole = delta.role;
if (delta.role === "assistant") {
pendingMessageBoundary = true;
latestSegment = "";
}
}
if (isAssistantDelta(delta)) {
if (delta.content) {
let text = stripStreamingEllipsis(hassMessage.text);
if (pendingMessageBoundary) {
text = joinWithBreak(text, delta.content);
pendingMessageBoundary = false;
} else {
text += delta.content;
}
latestSegment += delta.content;
hassMessage.text = text + STREAMING_ELLIPSIS;
streamingMarker = true;
}
if (delta.thinking_content) {
hassMessage.thinking += delta.thinking_content;
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
hassMessage.tool_calls[toolCall.id] = toolCall;
}
}
requestUpdate();
} else if (isToolResultDelta(delta)) {
if (hassMessage.tool_calls[delta.tool_call_id]) {
hassMessage.tool_calls[delta.tool_call_id].result = delta.result;
requestUpdate();
}
}
} else if (event.type === "intent-end") {
continueConversation = event.data.intent_output.continue_conversation;
const response = event.data.intent_output.response.speech.plain?.speech;
if (
event.data.intent_output.response.response_type === "error" &&
response
) {
setError(response);
return;
}
if (response) {
applyFinalResponse(response);
} else {
finalizeStreamingText();
}
pendingMessageBoundary = false;
requestUpdate();
}
};
return {
get continueConversation() {
return continueConversation;
},
get hassMessage() {
return hassMessage;
},
addMessage: () => {
addMessage(hassMessage);
},
setError,
processEvent,
};
};
+15 -11
View File
@@ -1,7 +1,11 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { atLeastVersion } from "../common/config/version";
import type { HomeAssistant, LogFileDisabledReason } from "../types";
import type {
HomeAssistantApi,
HomeAssistantConfig,
LogFileDisabledReason,
} from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { supervisorUrl } from "./hassio/common";
@@ -11,12 +15,12 @@ export interface LogProvider {
addon?: HassioAddonInfo;
}
const hasSupervisorCoreLogDownload = (hass: HomeAssistant): boolean =>
isComponentLoaded(hass.config, "hassio") &&
atLeastVersion(hass.config.version, 2025, 10);
const hasSupervisorCoreLogDownload = (config: HomeAssistantConfig): boolean =>
isComponentLoaded(config.config, "hassio") &&
atLeastVersion(config.config.version, 2025, 10);
export const fetchErrorLog = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "error_log");
export const fetchErrorLog = (api: HomeAssistantApi) =>
api.callApi<string>("GET", "error_log");
export const subscribeIntegrationLog = (
connection: Connection,
@@ -31,17 +35,17 @@ export const subscribeIntegrationLog = (
{ resubscribe: false }
);
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
hasSupervisorCoreLogDownload(hass)
export const getErrorLogDownloadUrl = (config: HomeAssistantConfig) =>
hasSupervisorCoreLogDownload(config)
? supervisorUrl("core/logs/latest")
: "/api/error_log";
export const getCoreLogFileDownloadUnavailableReason = (
hass: HomeAssistant
config: HomeAssistantConfig
): LogFileDisabledReason | undefined => {
if (hasSupervisorCoreLogDownload(hass)) {
if (hasSupervisorCoreLogDownload(config)) {
return undefined;
}
return hass.config.logging?.log_file_disabled_reason ?? undefined;
return config.config.logging?.log_file_disabled_reason ?? undefined;
};
+11 -11
View File
@@ -1,4 +1,4 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { HomeAssistant, HomeAssistantApi, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { AddonState } from "./addon";
import { supervisorUrl, type HassioResponse } from "./common";
@@ -157,25 +157,25 @@ export const fetchHassioInfo = async (
method: "get",
});
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
export const fetchHassioBoots = async (api: HomeAssistantApi) =>
api.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
export const fetchHassioLogsLegacy = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string
) =>
hass.callApi<string>(
api.callApi<string>(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
);
export const fetchHassioLogs = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string,
range?: string,
boot = 0
) =>
hass.callApiRaw(
api.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}`,
undefined,
@@ -187,13 +187,13 @@ export const fetchHassioLogs = async (
);
export const fetchHassioLogsFollow = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string,
signal: AbortSignal,
lines = 100,
boot = 0
) =>
hass.callApiRaw(
api.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow?lines=${lines}`,
undefined,
@@ -202,7 +202,7 @@ export const fetchHassioLogsFollow = async (
);
export const fetchHassioLogsFollowSkip = async (
hass: HomeAssistant,
api: HomeAssistantApi,
provider: string,
signal: AbortSignal,
cursor: string,
@@ -210,7 +210,7 @@ export const fetchHassioLogsFollowSkip = async (
lines = 100,
boot = 0
) =>
hass.callApiRaw(
api.callApiRaw(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs${boot !== 0 ? `/boots/${boot}` : ""}/follow`,
undefined,
+5 -1
View File
@@ -241,6 +241,10 @@ class HaPanelApp extends LitElement {
return;
}
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
return;
}
if (!addon.version) {
await this._showErrorAndNavigateHome(
addon.name,
@@ -324,7 +328,7 @@ class HaPanelApp extends LitElement {
}
// Check if user navigated away while we were fetching
if (this._getAddonSlug() !== addonSlug) {
if (this._getAddonSlug() !== addonSlug || !this.isConnected) {
return;
}
@@ -38,7 +38,6 @@ class SupervisorAppLogDashboard extends LitElement {
</div>
<div class="content">
<error-log-card
.hass=${this.hass}
.header=${this.addon.name}
.provider=${this.addon.slug}
.filter=${this._filter}
@@ -316,6 +316,12 @@ export class HaAutomationAddItems extends LitElement {
white-space: nowrap;
}
.selected-target state-badge {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.selected-target ha-icon,
.selected-target ha-svg-icon,
.selected-target ha-domain-icon {
@@ -4,6 +4,7 @@ import "../../../../components/ha-domain-icon";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
import "../../../../components/ha-state-icon";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
@@ -57,8 +58,20 @@ export const getTargetIcon = (
}
if (targetType === "entity" && states[targetId]) {
const stateObj = states[targetId];
const hasEntityPicture =
stateObj.attributes.entity_picture ||
stateObj.attributes.entity_picture_local;
if (hasEntityPicture) {
return html`<state-badge
.stateObj=${stateObj}
.slot=${slot}
></state-badge>`;
}
return html`<ha-state-icon
.stateObj=${states[targetId]}
.stateObj=${stateObj}
.slot=${slot}
></ha-state-icon>`;
}
@@ -30,6 +30,7 @@ import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/entity/state-badge";
import { showTargetDetailsDialog } from "../../../../components/target-picker/dialog/show-dialog-target-details";
import type { ConfigEntry } from "../../../../data/config_entries";
import {
@@ -680,6 +681,12 @@ export class HaAutomationRowTargets extends LitElement {
padding: var(--ha-space-1) 0;
}
.target state-badge,
ha-dropdown-item state-badge {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.target ha-floor-icon {
display: flex;
height: 32px;
@@ -689,18 +696,25 @@ export class HaAutomationRowTargets extends LitElement {
:host([size="s"]) {
min-height: 24px;
}
:host([size="s"]) .target {
height: 24px;
}
/* A default 24px icon would fill the whole small chip. */
:host([size="s"]) .target ha-floor-icon {
height: 24px;
}
:host([size="s"]) .target ha-icon,
:host([size="s"]) .target ha-svg-icon,
:host([size="s"]) .target ha-domain-icon,
:host([size="s"]) .target ha-floor-icon {
--mdc-icon-size: 16px;
}
:host([size="s"]) .target ha-floor-icon {
height: 24px;
:host([size="s"]) .target state-badge {
width: 16px;
height: 16px;
--mdc-icon-size: 16px;
}
button.target {
@@ -185,12 +185,17 @@ class MqttSubscribeCard extends LitElement {
this._subscribed();
this._subscribed = undefined;
} else {
this._subscribed = await subscribeMQTTTopic(
const unsubscribe = await subscribeMQTTTopic(
this.hass!,
this._topic,
(message) => this._handleMessage(message),
parseInt(this._qos)
);
if (!this.isConnected) {
unsubscribe();
return;
}
this._subscribed = unsubscribe;
}
}
@@ -61,6 +61,13 @@ class ZHADevicePage extends LitElement {
private _deviceRefreshInterval?: number;
public connectedCallback(): void {
super.connectedCallback();
if (this._device) {
this._startDeviceRefreshInterval();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._clearDeviceRefreshInterval();
@@ -191,7 +198,9 @@ class ZHADevicePage extends LitElement {
if (!tabs.includes(this._currTab)) {
this._currTab = tabs[0];
}
this._startDeviceRefreshInterval();
if (this.isConnected) {
this._startDeviceRefreshInterval();
}
} catch (_err: any) {
if (this.ieee === ieee) {
this._error = this.hass.localize(
+46 -24
View File
@@ -23,6 +23,7 @@ import {
import { classMap } from "lit/directives/class-map";
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import type { ContextType } from "@lit/context";
import { customElement, property, query, state } from "lit/decorators";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-alert";
@@ -40,11 +41,18 @@ import { getSignedPath } from "../../../data/auth";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { atLeastVersion } from "../../../common/config/version";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { debounce } from "../../../common/util/debounce";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import type { ConnectionStatus } from "../../../data/connection-status";
import {
apiContext,
configContext,
internationalizationContext,
} from "../../../data/context";
import {
fetchErrorLog,
getCoreLogFileDownloadUnavailableReason,
@@ -59,7 +67,7 @@ import {
fetchHassioLogsLegacy,
getHassioLogDownloadUrl,
} from "../../../data/hassio/supervisor";
import type { HomeAssistant } from "../../../types";
import type { HomeAssistantApi, HomeAssistantConfig } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import { showDownloadLogsDialog } from "./show-dialog-download-logs";
@@ -67,8 +75,6 @@ const NUMBER_OF_LINES = 100;
@customElement("error-log-card")
class ErrorLogCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public localizeFunc?: LocalizeFunc<any>;
@property() public filter = "";
@@ -80,6 +86,20 @@ class ErrorLogCard extends LitElement {
@property({ attribute: "allow-switch", type: Boolean }) public allowSwitch =
false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: HomeAssistantConfig;
@query(".error-log") private _logElement?: HTMLElement;
@query("#scroll-top-marker") private _scrollTopMarkerElement?: HTMLElement;
@@ -129,15 +149,15 @@ class ErrorLogCard extends LitElement {
const streaming =
this._streamSupported &&
this.provider &&
isComponentLoaded(this.hass.config, "hassio") &&
isComponentLoaded(this._config.config, "hassio") &&
this._loadingState !== "loading";
const hasBoots = this._streamSupported && Array.isArray(this._boots);
const localize = this.localizeFunc || this.hass.localize;
const localize = this.localizeFunc || this._localize;
const logFileDownloadUnavailableReason =
!this.provider || this.provider === "core"
? getCoreLogFileDownloadUnavailableReason(this.hass)
? getCoreLogFileDownloadUnavailableReason(this._config)
: undefined;
return html`
@@ -250,7 +270,7 @@ class ErrorLogCard extends LitElement {
slot="icon"
.path=${mdiFolderTextOutline}
></ha-svg-icon>
${this.hass.localize(
${this._localize(
"ui.panel.config.logs.show_condensed_logs"
)}
</ha-dropdown-item>`
@@ -352,7 +372,7 @@ class ErrorLogCard extends LitElement {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._streamSupported = atLeastVersion(
this.hass.config.version,
this._config.config.version,
2024,
11
);
@@ -365,7 +385,7 @@ class ErrorLogCard extends LitElement {
this._handleConnectionStatus
);
this.hass.loadFragmentTranslation("config");
this._i18n.loadFragmentTranslation("config");
}
if (changedProps.has("provider")) {
@@ -421,7 +441,7 @@ class ErrorLogCard extends LitElement {
private async _downloadLogs(): Promise<void> {
if (
(!this.provider || this.provider === "core") &&
getCoreLogFileDownloadUnavailableReason(this.hass)
getCoreLogFileDownloadUnavailableReason(this._config)
) {
return;
}
@@ -438,12 +458,13 @@ class ErrorLogCard extends LitElement {
const downloadUrl =
this.provider && this.provider !== "core"
? getHassioLogDownloadUrl(this.provider)
: getErrorLogDownloadUrl(this.hass);
: getErrorLogDownloadUrl(this._config);
const logFileName =
this.provider && this.provider !== "core"
? `${this.provider}_${timeString}.log`
: `home-assistant_${timeString}.log`;
const signedUrl = await getSignedPath(this.hass, downloadUrl);
const signedUrl = await getSignedPath(this._api, downloadUrl);
fileDownload(signedUrl.path, logFileName);
}
}
@@ -461,7 +482,7 @@ class ErrorLogCard extends LitElement {
if (
(!this.provider || this.provider === "core") &&
getCoreLogFileDownloadUnavailableReason(this.hass)
getCoreLogFileDownloadUnavailableReason(this._config)
) {
this._loadingState = "loaded";
return;
@@ -469,7 +490,7 @@ class ErrorLogCard extends LitElement {
const streamLogs =
this._streamSupported &&
isComponentLoaded(this.hass.config, "hassio") &&
isComponentLoaded(this._config.config, "hassio") &&
this.provider;
try {
@@ -484,7 +505,7 @@ class ErrorLogCard extends LitElement {
if (!retry) {
// check if there are any logs at all
const testResponse = await fetchHassioLogs(
this.hass,
this._api,
this.provider!,
`entries=:-1:`,
this._boot
@@ -499,7 +520,7 @@ class ErrorLogCard extends LitElement {
if (retry && this._firstCursor) {
response = await fetchHassioLogsFollowSkip(
this.hass,
this._api,
this.provider!,
this._logStreamAborter.signal,
this._firstCursor,
@@ -509,7 +530,7 @@ class ErrorLogCard extends LitElement {
);
} else {
response = await fetchHassioLogsFollow(
this.hass,
this._api,
this.provider!,
this._logStreamAborter.signal,
NUMBER_OF_LINES,
@@ -578,10 +599,11 @@ class ErrorLogCard extends LitElement {
// fallback to old method
this._streamSupported = false;
let logs = "";
if (isComponentLoaded(this.hass.config, "hassio") && this.provider) {
logs = await fetchHassioLogsLegacy(this.hass, this.provider);
if (isComponentLoaded(this._config.config, "hassio") && this.provider) {
logs = await fetchHassioLogsLegacy(this._api, this.provider);
} else {
logs = await fetchErrorLog(this.hass);
logs = await fetchErrorLog(this._api);
}
if (logs) {
@@ -601,7 +623,7 @@ class ErrorLogCard extends LitElement {
return;
}
this._error = (this.localizeFunc || this.hass.localize)(
this._error = (this.localizeFunc || this._localize)(
"ui.panel.config.logs.failed_get_logs",
{
provider: this.provider,
@@ -659,7 +681,7 @@ class ErrorLogCard extends LitElement {
this._logElement.scrollHeight - this._logElement.scrollTop;
this._loadingPrevState = "loading";
const response = await fetchHassioLogs(
this.hass,
this._api,
this.provider,
`entries=${this._firstCursor}:-100:100`,
this._boot
@@ -715,10 +737,10 @@ class ErrorLogCard extends LitElement {
private async _loadBoots() {
if (
this._streamSupported &&
isComponentLoaded(this.hass.config, "hassio")
isComponentLoaded(this._config.config, "hassio")
) {
try {
const { data } = await fetchHassioBoots(this.hass);
const { data } = await fetchHassioBoots(this._api);
const boots = Object.keys(data.boots)
.map(Number)
.sort((a, b) => b - a);
-1
View File
@@ -177,7 +177,6 @@ export class HaConfigLogs extends LitElement {
></system-log-card>
`
: html`<error-log-card
.hass=${this.hass}
.header=${
this._logProviders.find(
(p) => p.key === this._selectedLogProvider
+12 -9
View File
@@ -50,7 +50,7 @@ class PanelHome extends SubscribeMixin(LitElement) {
@state() private _lovelace?: Lovelace;
@state() private _config: FrontendSystemData["home"] = {};
@state() private _config?: HomeFrontendSystemData;
@state() private _securityConfig: SecurityFrontendSystemData = {};
@@ -83,8 +83,8 @@ class PanelHome extends SubscribeMixin(LitElement) {
}
private get _showBanner(): boolean {
// Don't show if already dismissed
if (this._config.welcome_banner_dismissed) {
// Don't show until the config loaded or if already dismissed
if (!this._config || this._config.welcome_banner_dismissed) {
return false;
}
// Don't show if HA is not running
@@ -197,7 +197,7 @@ class PanelHome extends SubscribeMixin(LitElement) {
if (homeResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error("Failed to load home configuration:", homeResult.reason);
this._config = {};
this._config = undefined;
} else {
this._config = homeResult.value || {};
}
@@ -260,7 +260,7 @@ class PanelHome extends SubscribeMixin(LitElement) {
private _editHome = () => {
showEditHomeDialog(this, {
config: this._config,
config: this._config ?? {},
saveConfig: async (config) => {
await this._saveConfig(config);
},
@@ -382,6 +382,9 @@ class PanelHome extends SubscribeMixin(LitElement) {
private _learnMore() {
showNewOverviewDialog(this, {
dismiss: async () => {
if (!this._config) {
return;
}
const newConfig = {
...this._config,
welcome_banner_dismissed: true,
@@ -397,11 +400,11 @@ class PanelHome extends SubscribeMixin(LitElement) {
strategy: {
type: "home",
alert_entities: this._securityConfig.alert_entities,
favorite_entities: this._config.favorite_entities,
favorite_entities: this._config?.favorite_entities,
home_panel: true,
hide_welcome_message: this._config.hide_welcome_message,
hide_suggested_entities: this._config.hide_suggested_entities,
shortcuts: this._config.shortcuts,
hide_welcome_message: this._config?.hide_welcome_message,
hide_suggested_entities: this._config?.hide_suggested_entities,
shortcuts: this._config?.shortcuts,
},
};
}
@@ -30,7 +30,7 @@ export class HuiRecoveryModeCard extends LitElement implements LovelaceCard {
"ui.panel.lovelace.cards.recovery-mode.description"
)}
</div>
<error-log-card .hass=${this.hass} provider="core"></error-log-card>
<error-log-card provider="core"></error-log-card>
</ha-card>
`;
}
+9 -3
View File
@@ -465,14 +465,20 @@ class HUIRoot extends LitElement {
!view.visible.some((e) => e.user === this.hass!.user?.id)) ||
view.visible === false);
const tabs = html`<ha-tab-group @wa-tab-show=${this._handleViewSelected}>
const tabs = html`<ha-tab-group
.active=${typeof this._curView === "number" ? String(this._curView) : ""}
@wa-tab-show=${this._handleViewSelected}
>
${views.map((view, index) => {
const icon_and_title =
view.show_icon_and_title && view.icon && view.title;
const icon_only = view.icon && !icon_and_title;
const title_only = !icon_only && !icon_and_title;
const current = this._curView === index;
const hidden =
!this._editMode && (view.subview || _isTabHiddenForUser(view));
!this._editMode &&
!current &&
(view.subview || _isTabHiddenForUser(view));
const tabContent = html`
${
icon_only || icon_and_title
@@ -497,7 +503,7 @@ class HUIRoot extends LitElement {
<ha-tab-group-tab
slot="nav"
panel=${index}
.active=${this._curView === index}
.active=${current}
.disabled=${hidden}
aria-label=${ifDefined(view.title)}
data-path=${view.path || index}
@@ -1,3 +1,5 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { dataTableQueryParamConfig } from "../../../common/url/data-table-query-params";
import {
@@ -13,6 +15,7 @@ import {
} from "../../../common/url/query-params";
import type { DataTableRowData } from "../../../components/data-table/ha-data-table";
import type { RepositoryBase } from "../../../data/marketplace/repository";
import type { HomeAssistant } from "../../../types";
import type { MarketplaceTab } from "./ha-marketplace-dashboard";
export const STATUS_ORDER = [
@@ -51,6 +54,74 @@ export type RepositoryFilters = Partial<
>
>;
export interface MarketplaceRepository extends RepositoryBase {
update_skipped?: boolean;
}
export const marketplaceUpdateEntityIds = (
entities: HomeAssistant["entities"]
): string[] =>
Object.values(entities)
.filter(
(entity) =>
entity.platform === "marketplace" &&
computeDomain(entity.entity_id) === "update" &&
entity.device_id
)
.map((entity) => entity.entity_id);
// The skip is kept on the update entity, its device names the repository
export const skippedUpdateRepositories = (
entityIds: string[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
states: HassEntities
): string[] =>
entityIds
.flatMap((entityId) => {
const entity = entities[entityId];
const attributes = states[entityId]?.attributes;
if (
!entity?.device_id ||
!attributes?.latest_version ||
attributes.skipped_version !== attributes.latest_version
) {
return [];
}
const repositoryId = devices[entity.device_id]?.identifiers.find(
([domain]) => domain === "marketplace"
)?.[1];
return repositoryId ? [repositoryId] : [];
})
.sort();
// A skipped update leaves the repository installed. A restart it waits for
// is still needed, so that status stays.
export const applySkippedUpdates = (
repositories: RepositoryBase[],
skipped: string[]
): MarketplaceRepository[] =>
skipped.length
? repositories.map((repository): MarketplaceRepository => {
if (!repository.pending_upgrade || !skipped.includes(repository.id)) {
return repository;
}
return {
...repository,
update_skipped: true,
pending_upgrade: false,
status:
repository.status === "pending-upgrade"
? "installed"
: repository.status,
};
})
: repositories;
export const statusesOfTab = (
tab: MarketplaceTab
): readonly (typeof STATUS_ORDER)[number][] =>
@@ -1,5 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { ContextType } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import {
mdiCheckCircleOutline,
mdiCompassOutline,
@@ -11,7 +12,7 @@ import {
mdiStore,
mdiViewGridOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoize from "memoize-one";
@@ -21,12 +22,14 @@ import type {
HASSDomEvent,
} from "../../../common/dom/fire_event";
import { consume } from "../../../common/decorators/consume";
import { consumeEntityStates } from "../../../common/decorators/consume-context-entry";
import { storage } from "../../../common/decorators/storage";
import { transform } from "../../../common/decorators/transform";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { navigate } from "../../../common/navigate";
import { formatNumber } from "../../../common/number/format_number";
import { marketplaceFilterQueryParamConfig } from "../../../common/url/marketplace-query-params";
import { shallowEqual } from "../../../common/util/shallow-equal";
import type {
DataTableColumnContainer,
SelectionChangedEvent,
@@ -68,6 +71,8 @@ import type { MarketplaceData } from "../../../data/marketplace/marketplace";
import {
apiContext,
configContext,
devicesContext,
entitiesContext,
internationalizationContext,
uiContext,
} from "../../../data/context";
@@ -82,9 +87,12 @@ import {
} from "../../../data/marketplace/repository";
import { haStyle } from "../../../resources/styles";
import {
applySkippedUpdates,
browseLinkFromUrl,
filterRepositories,
filtersOfTab,
marketplaceUpdateEntityIds,
skippedUpdateRepositories,
SOURCE_FILTER,
SOURCE_ORDER,
STATUS_FILTER,
@@ -93,7 +101,10 @@ import {
statusFilterOfTab,
TYPE_FILTER,
} from "./dashboard-repositories";
import type { RepositoryFilters } from "./dashboard-repositories";
import type {
MarketplaceRepository,
RepositoryFilters,
} from "./dashboard-repositories";
import { documentationUrl } from "../../../util/documentation-url";
import { renderRepositoryIcon } from "../tools/repository-icon";
import {
@@ -176,6 +187,20 @@ export class HaMarketplaceDashboard extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consumeEntityStates({ entityIdPath: ["_marketplaceUpdateEntityIds"] })
private _states?: HassEntities;
@state()
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@state() private _marketplaceUpdateEntityIds: string[] = [];
@storage({
storage: "sessionStorage",
key: "marketplace-dashboard-table-filters",
@@ -254,10 +279,18 @@ export class HaMarketplaceDashboard extends LitElement {
);
// Stored settings ignore what is set before the first update
protected willUpdate() {
protected willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._applyLink();
}
// The states of these are only selected after this runs, so the skipped
// updates are worked out in render
if (changedProps.has("_entities")) {
this._marketplaceUpdateEntityIds = marketplaceUpdateEntityIds(
this._entities
);
}
}
// Links, like See all on Discover, say in the URL what to show. The router
@@ -286,11 +319,19 @@ export class HaMarketplaceDashboard extends LitElement {
}
protected render(): TemplateResult {
const marketplaceRepositories = this._applySkippedUpdates(
this.marketplace.repositories,
this._skippedUpdateRepositories(
this._marketplaceUpdateEntityIds,
this._entities,
this._devices,
this._states ?? {}
)
);
const tabs = this._tabs(
this._i18n.localize,
this.marketplace.repositories.filter(
(repository) => repository.pending_upgrade
).length
marketplaceRepositories.filter((repository) => repository.pending_upgrade)
.length
);
if (this.tab === "discover") {
@@ -310,7 +351,7 @@ export class HaMarketplaceDashboard extends LitElement {
const filters = this._filtersOfTab(this._filters, this.tab);
const repositories = this._filterRepositories(
this._repositoriesOfTab(this.marketplace.repositories, this.tab),
this._repositoriesOfTab(marketplaceRepositories, this.tab),
this._i18n.localize,
filters
);
@@ -503,6 +544,16 @@ export class HaMarketplaceDashboard extends LitElement {
private _repositoriesOfTab = memoize(repositoriesOfTab);
private _skippedUpdateRepositories = memoize(skippedUpdateRepositories);
// Registry changes give a new list of the same skipped updates, the table is
// left as it is
private _applySkippedUpdates = memoize(
applySkippedUpdates,
([repositories, skipped], [lastRepositories, lastSkipped]) =>
repositories === lastRepositories && shallowEqual(skipped, lastSkipped)
);
private _filterRepositories = memoize(filterRepositories);
private _filtersOfTab = memoize(filtersOfTab);
@@ -513,7 +564,7 @@ export class HaMarketplaceDashboard extends LitElement {
narrow: boolean,
darkMode: boolean | undefined,
markInstalled: boolean
): DataTableColumnContainer<RepositoryBase> => ({
): DataTableColumnContainer<MarketplaceRepository> => ({
icon: {
title: "",
label: localizeFunc("ui.panel.marketplace.column.icon"),
@@ -521,7 +572,7 @@ export class HaMarketplaceDashboard extends LitElement {
hidden: false,
moveable: false,
showNarrow: true,
template: (repository: RepositoryBase) =>
template: (repository: MarketplaceRepository) =>
renderRepositoryIcon(repository, {
darkMode,
hassUrl: this._config.auth.data.hassUrl,
@@ -531,6 +582,9 @@ export class HaMarketplaceDashboard extends LitElement {
updateLabel: localizeFunc(
"ui.panel.marketplace.repository_status.pending-upgrade"
),
skippedLabel: localizeFunc(
"ui.panel.marketplace.repository_status.update_skipped"
),
}),
},
name: {
+35 -15
View File
@@ -1,4 +1,8 @@
import { mdiArrowUpCircle, mdiCheckCircle } from "@mdi/js";
import {
mdiArrowUpCircle,
mdiArrowUpCircleOutline,
mdiCheckCircle,
} from "@mdi/js";
import { html, nothing } from "lit";
import "../../../components/ha-svg-icon";
import type { RepositoryBase } from "../../../data/marketplace/repository";
@@ -11,6 +15,7 @@ interface RepositoryIconOptions {
// Leave it out where everything shown is installed, a mark would say nothing
installedLabel?: string;
updateLabel?: string;
skippedLabel?: string;
}
// A corner of the icon, ringed so it stands out on any brand image
@@ -20,8 +25,14 @@ const BADGE_STYLE =
// Like the icons on the devices page, the category icon without a domain.
// Styled inline, as the tables render it where the styles of a page do not reach.
export const renderRepositoryIcon = (
repository: RepositoryBase,
{ darkMode, hassUrl, installedLabel, updateLabel }: RepositoryIconOptions
repository: RepositoryBase & { update_skipped?: boolean },
{
darkMode,
hassUrl,
installedLabel,
updateLabel,
skippedLabel,
}: RepositoryIconOptions
) =>
html`<div style="position: relative; display: flex">
${
@@ -44,23 +55,32 @@ export const renderRepositoryIcon = (
></ha-svg-icon>`
}
${
// An update says more than that it is installed, so it takes its place
updateLabel && repository.pending_upgrade
// A skipped update is still waiting, but muted as it was set aside
skippedLabel && repository.update_skipped
? html`<ha-svg-icon
class="update-badge"
class="skipped-badge"
role="img"
aria-label=${updateLabel}
.path=${mdiArrowUpCircle}
style="${BADGE_STYLE}; color: var(--warning-color)"
aria-label=${skippedLabel}
.path=${mdiArrowUpCircleOutline}
style="${BADGE_STYLE}; color: var(--secondary-text-color)"
></ha-svg-icon>`
: installedLabel && repository.installed
: // An update says more than that it is installed, so it takes its place
updateLabel && repository.pending_upgrade
? html`<ha-svg-icon
class="installed-badge"
class="update-badge"
role="img"
aria-label=${installedLabel}
.path=${mdiCheckCircle}
style="${BADGE_STYLE}; color: var(--success-color)"
aria-label=${updateLabel}
.path=${mdiArrowUpCircle}
style="${BADGE_STYLE}; color: var(--warning-color)"
></ha-svg-icon>`
: nothing
: installedLabel && repository.installed
? html`<ha-svg-icon
class="installed-badge"
role="img"
aria-label=${installedLabel}
.path=${mdiCheckCircle}
style="${BADGE_STYLE}; color: var(--success-color)"
></ha-svg-icon>`
: nothing
}
</div>`;
+2 -1
View File
@@ -12779,7 +12779,8 @@
"pending-upgrade": "Update available",
"installed": "Installed",
"new": "New",
"default": "Available"
"default": "Available",
"update_skipped": "Update skipped"
},
"menu": {
"custom_repositories": "Custom repositories",
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import {
createAssistMessageProcessor,
type AssistMessage,
} from "../../src/data/assist_chat";
import type {
ConversationChatLogAssistantDelta,
PipelineRunEvent,
} from "../../src/data/assist_pipeline";
const assistantDelta = (
delta: Partial<ConversationChatLogAssistantDelta>
): PipelineRunEvent => ({
type: "intent-progress",
timestamp: "2026-09-21T00:00:00.000Z",
data: { chat_log_delta: delta },
});
const toolCall = {
id: "call-1",
tool_name: "get_weather",
tool_args: {},
external: false,
};
const toolResultDelta: PipelineRunEvent = {
type: "intent-progress",
timestamp: "2026-09-21T00:00:01.000Z",
data: {
chat_log_delta: {
role: "tool_result",
agent_id: "agent",
tool_call_id: "call-1",
tool_name: "get_weather",
result: { data: { temperature: "21 °C" }, error: false },
created: "2026-09-21T00:00:01.000Z",
},
},
};
const intentEnd = (speech?: string): PipelineRunEvent => ({
type: "intent-end",
timestamp: "2026-09-21T00:00:02.000Z",
data: {
processed_locally: false,
intent_output: {
conversation_id: "conversation-id",
response: {
language: "en",
response_type: "query_answer",
speech:
speech === undefined
? {}
: {
plain: { extra_data: {}, speech },
ssml: { extra_data: {}, speech },
},
data: { success: [], failed: [] },
},
continue_conversation: false,
},
},
});
const createProcessor = (added: AssistMessage[] = []) =>
createAssistMessageProcessor({
addMessage: (message) => added.push(message),
requestUpdate: vi.fn(),
});
describe("createAssistMessageProcessor", () => {
it("keeps the beginning of a reply that contains tool calls", () => {
const processor = createProcessor();
processor.processEvent(
assistantDelta({
role: "assistant",
content: "Let me check the weather for you.",
})
);
processor.processEvent(assistantDelta({ tool_calls: [toolCall] }));
processor.processEvent(toolResultDelta);
processor.processEvent(
assistantDelta({
role: "assistant",
content: "It is 21 degrees in Utrecht.",
})
);
processor.processEvent(intentEnd("It is 21 degrees in Utrecht."));
expect(processor.hassMessage.text).toBe(
"Let me check the weather for you.\n\nIt is 21 degrees in Utrecht."
);
});
it("does not duplicate a reply that was fully streamed", () => {
const processor = createProcessor();
processor.processEvent(
assistantDelta({ role: "assistant", content: "The lights " })
);
processor.processEvent(assistantDelta({ content: "are on." }));
processor.processEvent(intentEnd("The lights are on."));
expect(processor.hassMessage.text).toBe("The lights are on.");
});
it("uses the response when nothing was streamed", () => {
const processor = createProcessor();
processor.processEvent(intentEnd("The lights are on."));
expect(processor.hassMessage.text).toBe("The lights are on.");
});
it("keeps a message boundary when role and content arrive separately", () => {
const processor = createProcessor();
processor.processEvent(
assistantDelta({ role: "assistant", content: "Let me check." })
);
processor.processEvent(assistantDelta({ role: "assistant" }));
processor.processEvent(assistantDelta({ content: "Done." }));
processor.processEvent(intentEnd("Done."));
expect(processor.hassMessage.text).toBe("Let me check.\n\nDone.");
});
it("removes the streaming ellipsis when there is no spoken response", () => {
const processor = createProcessor();
processor.processEvent(
assistantDelta({ role: "assistant", content: "Done." })
);
processor.processEvent(intentEnd());
expect(processor.hassMessage.text).toBe("Done.");
});
it("keeps streamed content and adds the error as its own message", () => {
const added: AssistMessage[] = [];
const processor = createProcessor(added);
processor.addMessage();
processor.processEvent(
assistantDelta({ role: "assistant", content: "Checking the sensor." })
);
processor.setError("Timeout running pipeline");
expect(added).toHaveLength(2);
expect(added[0].text).toBe("Checking the sensor.");
expect(added[1].text).toBe("Timeout running pipeline");
expect(added[1].error).toBe(true);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { provideHass } from "../../../src/fake_data/provide_hass";
import type { HomeFrontendSystemData } from "../../../src/data/frontend";
import "../../../src/panels/home/ha-panel-home";
type HomePanel = HTMLElement & {
hass: unknown;
updateComplete: Promise<boolean>;
_showBanner: boolean;
_loadConfigPromise?: Promise<void>;
[key: string]: unknown;
};
for (const tag of ["ha-button", "ha-svg-icon"]) {
if (!customElements.get(tag)) {
customElements.define(tag, class extends HTMLElement {});
}
}
vi.stubGlobal(
"ResizeObserver",
class {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
);
vi.mock("../../../src/components/ha-button", () => ({}));
vi.mock("../../../src/components/ha-svg-icon", () => ({}));
vi.mock("../../../src/panels/lovelace/hui-root", () => ({}));
vi.mock("../../../src/data/frontend", async (importOriginal) => {
const actual = await importOriginal<object>();
return {
...actual,
fetchFrontendSystemData: vi.fn(),
saveFrontendSystemData: vi.fn(),
subscribeFrontendSystemData: vi.fn(() => () => undefined),
};
});
vi.mock("../../../src/panels/home/dialogs/show-dialog-new-overview", () => ({
showNewOverviewDialog: vi.fn(),
}));
const STORED: HomeFrontendSystemData = {
welcome_banner_dismissed: false,
favorite_entities: ["light.kitchen", "climate.living"],
shortcuts: [
{ type: "summary", key: "light" },
{ type: "custom", path: "/energy", label: "Energy" },
],
};
const openPanel = async (fetchImpl: () => Promise<any>) => {
const { fetchFrontendSystemData } =
await import("../../../src/data/frontend");
vi.mocked(fetchFrontendSystemData).mockImplementation(fetchImpl as any);
const host = document.createElement("div");
// No legacy overview panel: the banner decision must rest on loaded config.
const hass = provideHass(host, { panels: {} });
document.body.append(host);
const panel = document.createElement("ha-panel-home") as unknown as HomePanel;
host.append(panel);
panel.hass = hass;
await panel.updateComplete;
await panel._loadConfigPromise;
return panel;
};
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("home panel welcome banner", () => {
it("stays hidden when the home config load is rejected", async () => {
const { saveFrontendSystemData } =
await import("../../../src/data/frontend");
const { showNewOverviewDialog } =
await import("../../../src/panels/home/dialogs/show-dialog-new-overview");
let dismissed: Promise<void> | undefined;
vi.mocked(showNewOverviewDialog).mockImplementation(((
_el: any,
opts: any
) => {
dismissed = opts.dismiss();
}) as any);
const panel = await openPanel(async () => {
throw new Error("no answer");
});
await panel.updateComplete;
expect(panel._showBanner).toBe(false);
// Defense in depth: even if invoked directly, dismiss must not persist
// an unloaded (empty) config over stored favourites/shortcuts.
(panel["_learnMore"] as () => void)();
await dismissed;
await panel.updateComplete;
expect(vi.mocked(saveFrontendSystemData)).not.toHaveBeenCalled();
});
it("dismissing saves the loaded favourites, not an empty config", async () => {
const { saveFrontendSystemData } =
await import("../../../src/data/frontend");
const { showNewOverviewDialog } =
await import("../../../src/panels/home/dialogs/show-dialog-new-overview");
let dismissed: Promise<void> | undefined;
vi.mocked(showNewOverviewDialog).mockImplementation(((
_el: any,
opts: any
) => {
dismissed = opts.dismiss();
}) as any);
const panel = await openPanel(async () => ({ ...STORED }));
await panel.updateComplete;
expect(panel._showBanner).toBe(true);
(panel["_learnMore"] as () => void)();
await dismissed;
await panel.updateComplete;
expect(vi.mocked(saveFrontendSystemData)).toHaveBeenCalledTimes(1);
const saved = vi.mocked(saveFrontendSystemData).mock.calls[0][2] as any;
expect(saved.favorite_entities).toEqual(STORED.favorite_entities);
expect(saved.shortcuts).toEqual(STORED.shortcuts);
expect(saved.welcome_banner_dismissed).toBe(true);
});
});
@@ -1,15 +1,25 @@
import { describe, expect, it } from "vitest";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { DataTableRowData } from "../../../src/components/data-table/ha-data-table";
import type { RepositoryBase } from "../../../src/data/marketplace/repository";
import type { HomeAssistant } from "../../../src/types";
import {
applySkippedUpdates,
browseLinkFromUrl,
browseUrl,
filterRepositories,
filtersOfTab,
marketplaceUpdateEntityIds,
skippedUpdateRepositories,
statusesOfTab,
statusFilterOfTab,
} from "../../../src/panels/marketplace/dashboards/dashboard-repositories";
import {
mockDevice,
mockEntity,
mockStateObj,
} from "../../common/entity/context/context-mock";
const localize = ((key: string) => key) as LocalizeFunc;
@@ -236,3 +246,104 @@ describe("filtersOfTab", () => {
]);
});
});
describe("skippedUpdateRepositories", () => {
const entities: HomeAssistant["entities"] = {
"update.mushroom": mockEntity({
entity_id: "update.mushroom",
platform: "marketplace",
device_id: "mushroom_device",
}),
"update.older": mockEntity({
entity_id: "update.older",
platform: "marketplace",
device_id: "older_device",
}),
"update.other": mockEntity({
entity_id: "update.other",
platform: "esphome",
device_id: "other_device",
}),
};
const devices: HomeAssistant["devices"] = {
mushroom_device: mockDevice({
id: "mushroom_device",
identifiers: [["marketplace", "444350375"]],
}),
older_device: mockDevice({
id: "older_device",
identifiers: [["marketplace", "1"]],
}),
other_device: mockDevice({
id: "other_device",
identifiers: [["esphome", "2"]],
}),
};
const update = (
entityId: string,
latest: string,
skipped: string | null
): HassEntity =>
mockStateObj({
entity_id: entityId,
state: "off",
attributes: { latest_version: latest, skipped_version: skipped },
});
it("finds the repositories of Marketplace update entities whose latest version is skipped", () => {
const states: HassEntities = {
"update.mushroom": update("update.mushroom", "v5.0.0", "v5.0.0"),
// A newer release than the one skipped is an update again
"update.older": update("update.older", "v2.0.0", "v1.0.0"),
"update.other": update("update.other", "2026.10.0", "2026.10.0"),
};
const updateEntityIds = marketplaceUpdateEntityIds(entities);
expect(updateEntityIds).toEqual(["update.mushroom", "update.older"]);
expect(
skippedUpdateRepositories(updateEntityIds, entities, devices, states)
).toEqual(["444350375"]);
});
});
describe("applySkippedUpdates", () => {
const repositories = [
repository("Skipped", {
status: "pending-upgrade",
installed: true,
pending_upgrade: true,
}),
repository("Restart", {
status: "pending-restart",
installed: true,
pending_upgrade: true,
}),
repository("Outdated", {
status: "pending-upgrade",
installed: true,
pending_upgrade: true,
}),
];
it("shows a skipped update as installed", () => {
const [skipped, restart, outdated] = applySkippedUpdates(repositories, [
"Skipped",
"Restart",
]);
expect(skipped).toMatchObject({
status: "installed",
pending_upgrade: false,
update_skipped: true,
});
// It still needs the restart
expect(restart).toMatchObject({
status: "pending-restart",
pending_upgrade: false,
});
expect(outdated).toBe(repositories[2]);
});
});