Compare commits

...
Author SHA1 Message Date
Paul Bottein afdc97ad38 Add a triggered by filter to the activity panel 2026-10-19 12:42:02 +02:00
Paul Bottein c4af43fa58 Let the history and activity panels compose their source filters 2026-10-19 12:34:14 +02: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
20 changed files with 1150 additions and 262 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 {
+263
View File
@@ -0,0 +1,263 @@
import type { ContextType } from "@lit/context";
import {
mdiDotsHorizontal,
mdiFilterVariantRemove,
mdiPuzzle,
mdiRobot,
mdiScriptText,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import {
formattersContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { LogbookCauseGroup } from "../data/logbook";
import { LOGBOOK_CAUSE_GROUPS, logbookUserCauseKey } from "../data/logbook";
import type { User } from "../data/user";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./user/ha-user-badge";
interface PersonUser {
id: string;
name: string;
}
const GROUP_ICONS: Record<LogbookCauseGroup, string> = {
automation: mdiRobot,
script: mdiScriptText,
integration: mdiPuzzle,
other: mdiDotsHorizontal,
};
@customElement("ha-filter-logbook-causes")
export class HaFilterLogbookCauses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: formattersContext, subscribe: true })
@state()
private _formatters!: ContextType<typeof formattersContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const count = this.value?.length ?? 0;
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.logbook.cause_filter.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${this._panel.showContent ? this._renderContent() : nothing}
`;
}
private _renderContent() {
const users = this._personUsers(
this._states,
this._formatters,
this._i18n.locale.language
);
const value = this.value ?? [];
return html`<div class="content" ${ref(this._content)}>
<ha-list-selectable
multi
controlled
aria-label=${this._localize("ui.components.logbook.cause_filter.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
users,
(user) => user.id,
(user) => {
const key = logbookUserCauseKey(user.id);
return html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${key}
.selected=${value.includes(key)}
>
<ha-user-badge slot="start" .user=${user as User}></ha-user-badge>
<span slot="headline">${user.name}</span>
</ha-list-item-option>`;
}
)}
${LOGBOOK_CAUSE_GROUPS.map(
(group) =>
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${group}
.selected=${value.includes(group)}
>
<ha-svg-icon
slot="start"
.path=${GROUP_ICONS[group]}
></ha-svg-icon>
<span slot="headline"
>${this._localize(
`ui.components.logbook.cause_filter.${group}`
)}</span
>
</ha-list-item-option>`
)}
</ha-list-selectable>
</div>`;
}
private _personUsers = memoizeOne(
(
states: ContextType<typeof statesContext>,
formatters: ContextType<typeof formattersContext>,
language: string | undefined
): PersonUser[] =>
Object.values(states)
.filter(
(stateObj) =>
computeStateDomain(stateObj) === "person" &&
stateObj.attributes.user_id
)
.map((stateObj) => ({
id: stateObj.attributes.user_id as string,
name: formatters.formatEntityName(stateObj),
}))
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _handleItemToggled(ev: CustomEvent<number>) {
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
const key = option?.value;
if (!key) {
return;
}
const value = new Set(this.value ?? []);
if (!value.delete(key)) {
value.add(key);
}
this.value = [...value];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
filterPanelStyles,
css`
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-user-badge {
width: 24px;
height: 24px;
font-size: var(--ha-font-size-xs);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-logbook-causes": HaFilterLogbookCauses;
}
}
+31 -82
View File
@@ -1,6 +1,6 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
@@ -10,8 +10,6 @@ import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-entity-types";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
@@ -39,6 +37,16 @@ export const countTargets = (target: HassServiceTarget): number =>
0
);
/** Sets one filter from the value of its `data-table-filter-changed` event. */
export const setSourceFilter = <T extends SourceFilters>(
filters: T,
key: keyof T,
value: DataTableFiltersValue
): T => ({
...filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
});
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
@@ -92,8 +100,8 @@ export const applySourceFilters = (
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
* Picks the targets a page shows. Meant to be placed in an `ha-filter-pane`,
* above the filters the page narrows them down with.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
@@ -104,6 +112,7 @@ export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public value: HassServiceTarget = {};
/** The filters the page applies, so the picker counts what they keep. */
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
@@ -116,10 +125,6 @@ export class HaSourcesPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
@@ -144,72 +149,16 @@ export class HaSourcesPicker extends LitElement {
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-entity-types
.value=${this.filters.types}
.expanded=${this._expandedFilter === "types"}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._typesExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
private _activeFilter = memoizeOne(sourceFilterFunc);
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _typesChanged(ev: CustomEvent) {
this._filterChanged("types", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _typesExpanded(ev: CustomEvent) {
this._filterExpanded("types", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
@@ -241,28 +190,28 @@ export class HaSourcesPicker extends LitElement {
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over, but
keeps room for its list when the targets fill the pane. */
.filters.expanded {
flex: 1 1 auto;
min-height: min(360px, 100%);
}
`;
}
/** Lays out the filters that follow the picker in the pane. */
export const sourceFiltersStyles = css`
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over, but
keeps room for its list when the targets fill the pane. */
.filters.expanded {
flex: 1 1 auto;
min-height: min(360px, 100%);
}
`;
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+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,
};
};
+12
View File
@@ -329,3 +329,15 @@ export const filterLogbookCompatibleEntities = (entity) => {
(domain === "sensor" && isNumericEntity(domain, entity, undefined));
return !continuous;
};
export type LogbookCauseGroup =
"automation" | "script" | "integration" | "other";
export const LOGBOOK_CAUSE_GROUPS: LogbookCauseGroup[] = [
"automation",
"script",
"integration",
"other",
];
export const logbookUserCauseKey = (userId: string) => `user/${userId}`;
+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;
}
@@ -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(
+66 -5
View File
@@ -13,10 +13,14 @@ import type {
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -45,6 +49,8 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane";
import type { HaFilterPane } from "../../components/ha-filter-pane";
import "../../components/ha-filter-entity-types";
import "../../components/ha-filter-integrations";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-icon-button";
import type { SourceFilters } from "../../components/ha-sources-picker";
@@ -52,6 +58,8 @@ import {
applySourceFilters,
countSourceFilters,
countTargets,
setSourceFilter,
sourceFiltersStyles,
} from "../../components/ha-sources-picker";
import "../../components/ha-spinner";
import "../../components/ha-top-app-bar-fixed";
@@ -105,6 +113,10 @@ class HaPanelHistory extends LitElement {
@state() private _filters: SourceFilters = {};
// Only one filter is expanded at a time, so that it can use the height
// that is left in the pane.
@state() private _expandedFilter?: string;
@storage({
key: "historySourceFilters",
state: false,
@@ -235,8 +247,30 @@ class HaPanelHistory extends LitElement {
"ui.panel.history.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
<div
class=${classMap({
filters: true,
expanded: !!this._expandedFilter,
})}
>
<ha-filter-entity-types
.value=${this._filters.types}
.expanded=${
this._expandedFilter === "ha-filter-entity-types"
}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this._filters.integrations}
.expanded=${
this._expandedFilter === "ha-filter-integrations"
}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
</div>
</ha-filter-pane>`
: nothing
}
@@ -368,6 +402,8 @@ class HaPanelHistory extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
// The filters label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
@@ -416,13 +452,37 @@ class HaPanelHistory extends LitElement {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
private _typesChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._filters = ev.detail.value;
this._setFilter("types", ev);
}
private _integrationsChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._setFilter("integrations", ev);
}
private _setFilter(
key: keyof SourceFilters,
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._filters = setSourceFilter(this._filters, key, ev.detail.value);
this._storedFilters = this._filters;
}
private _filterExpanded(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]> &
HASSDomTargetEvent<HTMLElement>
) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;
} else if (this._expandedFilter === ev.target.localName) {
this._expandedFilter = undefined;
}
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
@@ -797,6 +857,7 @@ class HaPanelHistory extends LitElement {
static get styles() {
return [
haStyle,
sourceFiltersStyles,
haStyleScrollbar,
css`
:host {
+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,
},
};
}
+51 -5
View File
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { throttle } from "../../common/util/throttle";
@@ -14,6 +15,7 @@ import { fetchUsers } from "../../data/user";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-renderer";
import type { LogbookNameDetail } from "./logbook-entry-model";
import { computeLogbookCauseKey, computeRowCause } from "./logbook-entry-model";
interface LogbookTimePeriod {
now: Date;
@@ -57,6 +59,9 @@ export class HaLogbook extends LitElement {
@property({ attribute: false }) public stateFilter?: string[];
/** Keys from `computeLogbookCauseKey` to keep. */
@property({ attribute: false }) public causeFilter?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public virtualize = false;
@@ -81,10 +86,12 @@ export class HaLogbook extends LitElement {
@state() private _traceContexts: TraceContexts = {};
@state() private _userIdToName = {};
@state() private _userIdToName: Record<string, string> = {};
@state() private _systemUserIds = new Set<string>();
@state() private _personUserIds = new Set<string>();
@state() private _error?: string;
private _unsubLogbook?: Promise<UnsubscribeFunc>;
@@ -103,7 +110,7 @@ export class HaLogbook extends LitElement {
private _readyListenerAttached = false;
public getEntries(): LogbookEntry[] {
return this._logbookEntries || [];
return this._shownEntries() ?? [];
}
protected render() {
@@ -119,7 +126,9 @@ export class HaLogbook extends LitElement {
</div>`;
}
if (this._logbookEntries === undefined) {
const entries = this._shownEntries();
if (entries === undefined) {
return html`
<div class="progress-wrapper">
<ha-spinner></ha-spinner>
@@ -127,7 +136,7 @@ export class HaLogbook extends LitElement {
`;
}
if (this._logbookEntries.length === 0) {
if (entries.length === 0) {
return html`<slot name="empty">
<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
@@ -144,7 +153,7 @@ export class HaLogbook extends LitElement {
.graphColor=${this.graphColor}
.showCause=${this.showCause}
.nameDetail=${this.nameDetail}
.entries=${this._logbookEntries}
.entries=${entries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
.systemUserIds=${this._systemUserIds}
@@ -153,6 +162,40 @@ export class HaLogbook extends LitElement {
`;
}
private _shownEntries(): LogbookEntry[] | undefined {
return this._filterEntriesByCause(
this._logbookEntries,
this.causeFilter,
this._userIdToName,
this._systemUserIds,
this._personUserIds
);
}
private _filterEntriesByCause = memoizeOne(
(
entries: LogbookEntry[] | undefined,
causeFilter: string[] | undefined,
userIdToName: Record<string, string>,
systemUserIds: Set<string>,
personUserIds: Set<string>
): LogbookEntry[] | undefined => {
if (!entries || !causeFilter?.length) {
return entries;
}
const causes = new Set(causeFilter);
return entries.filter((entry) =>
causes.has(
computeLogbookCauseKey(
entry,
computeRowCause(this.hass, entry, userIdToName, systemUserIds),
personUserIds
)
)
);
}
);
public async refresh(force = false) {
if (!force && (this._unsubLogbook || this._logbookEntries === undefined)) {
return;
@@ -471,6 +514,7 @@ export class HaLogbook extends LitElement {
private _updateUsers = throttle(async () => {
const userIdToName = {};
const systemUserIds = new Set<string>();
const personUserIds = new Set<string>();
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
@@ -483,6 +527,7 @@ export class HaLogbook extends LitElement {
) {
userIdToName[entity.attributes.user_id] =
this.hass.formatEntityName(entity);
personUserIds.add(entity.attributes.user_id);
}
}
@@ -501,6 +546,7 @@ export class HaLogbook extends LitElement {
this._userIdToName = userIdToName;
this._systemUserIds = systemUserIds;
this._personUserIds = personUserIds;
}, 60000);
static get styles() {
+94 -9
View File
@@ -9,11 +9,15 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -34,6 +38,9 @@ import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-entity-types";
import "../../components/ha-filter-integrations";
import "../../components/ha-filter-logbook-causes";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import type { HaFilterPane } from "../../components/ha-filter-pane";
@@ -41,6 +48,8 @@ import "../../components/ha-icon-button";
import {
applySourceFilters,
countSourceFilters,
setSourceFilter,
sourceFiltersStyles,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
@@ -59,6 +68,11 @@ import { csvDownload, csvSafeString } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
interface LogbookFilters extends SourceFilters {
/** Keys from `computeLogbookCauseKey`. */
causes?: string[];
}
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
@@ -77,7 +91,11 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
@state() private _filters: SourceFilters = {};
@state() private _filters: LogbookFilters = {};
// Only one filter is expanded at a time, so that it can use the height
// that is left in the pane.
@state() private _expandedFilter?: string;
@state() private _showSources?: boolean;
@@ -102,7 +120,7 @@ export class HaPanelLogbook extends LitElement {
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
private _storedFilters?: LogbookFilters;
public constructor() {
super();
@@ -113,7 +131,7 @@ export class HaPanelLogbook extends LitElement {
const entityIds = this._getEntityIds();
const filterCount = countSourceFilters(this._filters);
const sourceCount = countTargets(this._targetPickerValue) + filterCount;
const sourcesLabel = sourceCount
const sourcesLabel = entityIds
? this.hass.localize("ui.panel.logbook.sources_count", {
count: entityIds?.length ?? 0,
})
@@ -167,8 +185,38 @@ export class HaPanelLogbook extends LitElement {
"ui.panel.logbook.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
<div
class=${classMap({
filters: true,
expanded: !!this._expandedFilter,
})}
>
<ha-filter-entity-types
.value=${this._filters.types}
.expanded=${
this._expandedFilter === "ha-filter-entity-types"
}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this._filters.integrations}
.expanded=${
this._expandedFilter === "ha-filter-integrations"
}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-logbook-causes
.value=${this._filters.causes}
.expanded=${
this._expandedFilter === "ha-filter-logbook-causes"
}
@data-table-filter-changed=${this._causesChanged}
@expanded-changed=${this._filterExpanded}
></ha-filter-logbook-causes>
</div>
</ha-filter-pane>`
: nothing
}
@@ -198,6 +246,7 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._shownEntityIds}
.causeFilter=${this._shownCauses}
.narrow=${this.narrow}
show-cause
virtualize
@@ -253,13 +302,43 @@ export class HaPanelLogbook extends LitElement {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
private _typesChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._filters = ev.detail.value;
this._setFilter("types", ev);
}
private _integrationsChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._setFilter("integrations", ev);
}
private _causesChanged(
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._setFilter("causes", ev);
}
private _setFilter(
key: keyof LogbookFilters,
ev: HASSDomEvent<HASSDomEvents["data-table-filter-changed"]>
) {
this._filters = setSourceFilter(this._filters, key, ev.detail.value);
this._storedFilters = this._filters;
}
private _filterExpanded(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]> &
HASSDomTargetEvent<HTMLElement>
) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;
} else if (this._expandedFilter === ev.target.localName) {
this._expandedFilter = undefined;
}
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
@@ -282,12 +361,15 @@ export class HaPanelLogbook extends LitElement {
// the sources once it is closed instead of on every source change.
if (!this.narrow || !this._sourcesShown()) {
this._shownEntityIds = this._getEntityIds();
this._shownCauses = this._filters.causes;
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
// The filters label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
@@ -332,7 +414,7 @@ export class HaPanelLogbook extends LitElement {
)
: undefined;
if (!countSourceFilters(this._filters)) {
if (!this._filters.types?.length && !this._filters.integrations?.length) {
return targetEntities;
}
@@ -398,6 +480,8 @@ export class HaPanelLogbook extends LitElement {
private _shownEntityIds?: string[];
private _shownCauses?: string[];
// A list keyed on the states must keep its identity or ha-logbook resubscribes.
private _stableEntityIds(entityIds: string[]): string[] {
if (this._lastEntityIds && shallowEqual(this._lastEntityIds, entityIds)) {
@@ -580,6 +664,7 @@ export class HaPanelLogbook extends LitElement {
static get styles() {
return [
haStyle,
sourceFiltersStyles,
css`
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
+47 -11
View File
@@ -9,6 +9,7 @@ import { domainToName } from "../../data/integration";
import type { LogbookEntry } from "../../data/logbook";
import {
createHistoricState,
logbookUserCauseKey,
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
@@ -305,6 +306,46 @@ export const computeLogbookCause = (
export const isRunCause = (cause?: LogbookCause): boolean =>
cause?.type === "automation" || cause?.type === "script";
// A row shows the run over the user who started it; the dialog shows both.
export const computeRowCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
): LogbookCause | undefined => {
const contextCause = computeContextCause(hass, item);
if (isRunCause(contextCause)) {
return contextCause;
}
return computeUserCause(item, userIdToName, systemUserIds) ?? contextCause;
};
// Only the users linked to a person can be picked. The other users are
// accounts for tools like Node-RED, so they count as integrations. A service
// call without a user carries the domain of the service, not the integration
// that acted, so it is left in other.
export const computeLogbookCauseKey = (
item: LogbookEntry,
cause: LogbookCause | undefined,
personUserIds: Set<string>
): string => {
if (cause?.type === "user") {
return personUserIds.has(cause.userId!)
? logbookUserCauseKey(cause.userId!)
: "integration";
}
if (cause?.type === "automation" || cause?.type === "script") {
return cause.type;
}
if (
cause?.type === "integration" &&
item.context_event_type !== "call_service"
) {
return "integration";
}
return "other";
};
export type LogbookGlyph =
| { type: "state"; stateObj: HassEntity; icon?: string }
| { type: "automation"; script: boolean }
@@ -412,13 +453,6 @@ export const computeLogbookItem = (
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
: undefined;
const userCause = computeUserCause(
entry,
opts.userIdToName ?? {},
opts.systemUserIds
);
const contextCause = computeContextCause(hass, entry);
return {
category,
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
@@ -426,10 +460,12 @@ export const computeLogbookItem = (
name: display?.primary ?? entry.name,
context: display?.secondary,
value: computeLogbookValue(hass, entry, domain, historicStateObj),
// A row shows the run over the user who started it; the dialog shows both.
cause: isRunCause(contextCause)
? contextCause
: (userCause ?? contextCause),
cause: computeRowCause(
hass,
entry,
opts.userIdToName ?? {},
opts.systemUserIds
),
when: entry.when * 1000,
};
};
+7
View File
@@ -778,6 +778,13 @@
"time_pattern": "time pattern",
"homeassistant_stopping": "Home Assistant stopping",
"homeassistant_starting": "Home Assistant starting",
"cause_filter": {
"caption": "Triggered by",
"automation": "Automations",
"script": "Scripts",
"integration": "Integrations",
"other": "Other"
},
"cause": {
"by": "By {name}",
"by_automation": "By automation: {name}",
+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);
});
});