From 181514df6307319400598f561bbb2875a2d2f7f1 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 10 Mar 2026 15:57:16 +0900 Subject: [PATCH 1/8] feat: Refactor ignored apps to use bundle IDs directly Simplify ignored platforms logic by storing bundle IDs instead of app names. Extract dropdown options logic into separate function and remove unnecessary name-to-bundleId conversions. Update form handling to work directly with bundle IDs and improve variable naming for clarity. --- .../general/notification-app-options.test.ts | 34 ++++++++ .../general/notification-app-options.ts | 22 +++++ .../src/settings/general/notification.tsx | 87 +++++++------------ 3 files changed, 89 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/settings/general/notification-app-options.test.ts create mode 100644 apps/desktop/src/settings/general/notification-app-options.ts diff --git a/apps/desktop/src/settings/general/notification-app-options.test.ts b/apps/desktop/src/settings/general/notification-app-options.test.ts new file mode 100644 index 0000000000..6a203b8d68 --- /dev/null +++ b/apps/desktop/src/settings/general/notification-app-options.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; + +import { getIgnoredAppOptions } from "./notification-app-options"; + +describe("getIgnoredAppOptions", () => { + test("returns installed app matches for partial searches", () => { + const options = getIgnoredAppOptions({ + allInstalledApps: [ + { id: "us.zoom.xos", name: "Zoom Workplace" }, + { id: "com.tinyspeck.slackmacgap", name: "Slack" }, + ], + ignoredPlatforms: [], + inputValue: "zoom", + defaultIgnoredBundleIds: [], + }); + + expect(options).toEqual([{ id: "us.zoom.xos", name: "Zoom Workplace" }]); + }); + + test("filters out already ignored and default ignored apps", () => { + const options = getIgnoredAppOptions({ + allInstalledApps: [ + { id: "us.zoom.xos", name: "Zoom Workplace" }, + { id: "com.tinyspeck.slackmacgap", name: "Slack" }, + { id: "com.openai.chat", name: "ChatGPT" }, + ], + ignoredPlatforms: ["com.tinyspeck.slackmacgap"], + inputValue: "", + defaultIgnoredBundleIds: ["com.openai.chat"], + }); + + expect(options).toEqual([{ id: "us.zoom.xos", name: "Zoom Workplace" }]); + }); +}); diff --git a/apps/desktop/src/settings/general/notification-app-options.ts b/apps/desktop/src/settings/general/notification-app-options.ts new file mode 100644 index 0000000000..41330ce440 --- /dev/null +++ b/apps/desktop/src/settings/general/notification-app-options.ts @@ -0,0 +1,22 @@ +import type { InstalledApp } from "@hypr/plugin-detect"; + +export function getIgnoredAppOptions({ + allInstalledApps, + ignoredPlatforms, + inputValue, + defaultIgnoredBundleIds, +}: { + allInstalledApps: InstalledApp[] | undefined; + ignoredPlatforms: string[]; + inputValue: string; + defaultIgnoredBundleIds: string[] | undefined; +}) { + return (allInstalledApps ?? []).filter((app) => { + const matchesSearch = app.name + .toLowerCase() + .includes(inputValue.toLowerCase()); + const notAlreadyAdded = !ignoredPlatforms.includes(app.id); + const notDefaultIgnored = !(defaultIgnoredBundleIds ?? []).includes(app.id); + return matchesSearch && notAlreadyAdded && notDefaultIgnored; + }); +} diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 7a632724ab..4801adbd62 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -21,6 +21,8 @@ import { import { Switch } from "@hypr/ui/components/ui/switch"; import { cn } from "@hypr/utils"; +import { getIgnoredAppOptions } from "./notification-app-options"; + import { useConfigValues } from "~/shared/config"; import * as settings from "~/store/tinybase/store/settings"; @@ -72,12 +74,7 @@ export function NotificationSettingsView() { return allInstalledApps?.find((a) => a.id === bundleId)?.name ?? bundleId; }; - const nameToBundleId = (name: string) => { - return allInstalledApps?.find((a) => a.name === name)?.id ?? name; - }; - - const isDefaultIgnored = (appName: string) => { - const bundleId = nameToBundleId(appName); + const isDefaultIgnored = (bundleId: string) => { return defaultIgnoredBundleIds?.includes(bundleId) ?? false; }; @@ -121,7 +118,7 @@ export function NotificationSettingsView() { notification_event: configs.notification_event, notification_detect: configs.notification_detect, respect_dnd: configs.respect_dnd, - ignored_platforms: configs.ignored_platforms.map(bundleIdToName), + ignored_platforms: configs.ignored_platforms, mic_active_threshold: configs.mic_active_threshold, }, listeners: { @@ -133,9 +130,7 @@ export function NotificationSettingsView() { handleSetNotificationEvent(value.notification_event); handleSetNotificationDetect(value.notification_detect); handleSetRespectDnd(value.respect_dnd); - handleSetIgnoredPlatforms( - JSON.stringify(value.ignored_platforms.map(nameToBundleId)), - ); + handleSetIgnoredPlatforms(JSON.stringify(value.ignored_platforms)); handleSetMicActiveThreshold(value.mic_active_threshold); }, }); @@ -144,42 +139,33 @@ export function NotificationSettingsView() { configs.notification_event || configs.notification_detect; const ignoredPlatforms = form.getFieldValue("ignored_platforms"); - const installedApps = allInstalledApps?.map((app) => app.name) ?? []; - - const filteredApps = installedApps.filter((app) => { - const matchesSearch = app.toLowerCase().includes(inputValue.toLowerCase()); - const notAlreadyAdded = !ignoredPlatforms.includes(app); - const notDefaultIgnored = !isDefaultIgnored(app); - return matchesSearch && notAlreadyAdded && notDefaultIgnored; + const dropdownOptions = getIgnoredAppOptions({ + allInstalledApps, + ignoredPlatforms, + inputValue, + defaultIgnoredBundleIds, }); - const showCustomOption = - inputValue.trim() && - !filteredApps.some((app) => app.toLowerCase() === inputValue.toLowerCase()); - - const dropdownOptions = showCustomOption - ? [inputValue.trim(), ...filteredApps] - : filteredApps; - - const handleAddIgnoredApp = (appName: string) => { - const trimmedName = appName.trim(); + const handleAddIgnoredApp = (bundleId: string) => { if ( - !trimmedName || - ignoredPlatforms.includes(trimmedName) || - isDefaultIgnored(trimmedName) + !bundleId || + ignoredPlatforms.includes(bundleId) || + isDefaultIgnored(bundleId) ) { return; } - form.setFieldValue("ignored_platforms", [...ignoredPlatforms, trimmedName]); + form.setFieldValue("ignored_platforms", [...ignoredPlatforms, bundleId]); void form.handleSubmit(); setInputValue(""); setShowDropdown(false); setSelectedIndex(0); }; - const handleRemoveIgnoredApp = (app: string) => { - const updated = ignoredPlatforms.filter((a: string) => a !== app); + const handleRemoveIgnoredApp = (bundleId: string) => { + const updated = ignoredPlatforms.filter( + (appId: string) => appId !== bundleId, + ); form.setFieldValue("ignored_platforms", updated); void form.handleSubmit(); }; @@ -187,8 +173,9 @@ export function NotificationSettingsView() { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && inputValue.trim()) { e.preventDefault(); - if (dropdownOptions.length > 0) { - handleAddIgnoredApp(dropdownOptions[selectedIndex]); + const selectedApp = dropdownOptions[selectedIndex]; + if (selectedApp) { + handleAddIgnoredApp(selectedApp.id); } } else if (e.key === "ArrowDown") { e.preventDefault(); @@ -206,9 +193,9 @@ export function NotificationSettingsView() { !inputValue && ignoredPlatforms.length > 0 ) { - const lastApp = ignoredPlatforms[ignoredPlatforms.length - 1]; - if (!isDefaultIgnored(lastApp)) { - handleRemoveIgnoredApp(lastApp); + const lastBundleId = ignoredPlatforms[ignoredPlatforms.length - 1]; + if (!isDefaultIgnored(lastBundleId)) { + handleRemoveIgnoredApp(lastBundleId); } } }; @@ -317,11 +304,11 @@ export function NotificationSettingsView() { className="flex min-h-[38px] w-full cursor-text flex-wrap items-center gap-2 rounded-md border p-2" onClick={() => inputRef.current?.focus()} > - {ignoredPlatforms.map((app: string) => { - const isDefault = isDefaultIgnored(app); + {ignoredPlatforms.map((bundleId: string) => { + const isDefault = isDefaultIgnored(bundleId); return ( - {app} + {bundleIdToName(bundleId)} {isDefault && ( (default) @@ -343,7 +330,7 @@ export function NotificationSettingsView() { variant="ghost" size="sm" className="ml-0.5 h-3 w-3 p-0 hover:bg-transparent" - onClick={() => handleRemoveIgnoredApp(app)} + onClick={() => handleRemoveIgnoredApp(bundleId)} > @@ -370,10 +357,9 @@ export function NotificationSettingsView() {
{dropdownOptions.map((app, index) => { - const isCustom = showCustomOption && index === 0; return ( ); })} From 168796ac0056a5cf55cb6e2b1a172d569298c5e0 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 10 Mar 2026 16:25:59 +0900 Subject: [PATCH 2/8] feat: add platform inclusion to notification filtering Add included_platforms configuration alongside ignored_platforms to provide more granular control over app tracking. This allows users to explicitly include categorized apps that would normally be filtered out. Implement storage layer support for included platforms with JSON array serialization. Update MicNotificationPolicy to prioritize included platforms over default category-based filtering while maintaining ignored platform precedence. Add comprehensive test coverage for the new inclusion behavior. --- apps/desktop/src/settings/general/index.tsx | 3 + .../general/notification-app-options.test.ts | 81 +++++++++---- .../general/notification-app-options.ts | 84 +++++++++++-- .../src/settings/general/notification.tsx | 113 +++++++++++------- apps/desktop/src/shared/config/index.ts | 17 ++- .../persister/settings/persister.test.ts | 6 + .../tinybase/persister/settings/transform.ts | 1 + .../src/store/tinybase/store/settings.ts | 11 ++ crates/db-user/src/config_types.rs | 3 + packages/store/src/tinybase.ts | 1 + packages/store/src/zod.ts | 1 + plugins/detect/js/bindings.gen.ts | 8 ++ plugins/detect/src/commands.rs | 10 ++ plugins/detect/src/ext.rs | 16 +++ plugins/detect/src/lib.rs | 1 + plugins/detect/src/policy.rs | 44 ++++++- 16 files changed, 321 insertions(+), 79 deletions(-) diff --git a/apps/desktop/src/settings/general/index.tsx b/apps/desktop/src/settings/general/index.tsx index cbf3b1452b..dbec4b0369 100644 --- a/apps/desktop/src/settings/general/index.tsx +++ b/apps/desktop/src/settings/general/index.tsx @@ -42,6 +42,9 @@ function useSettingsForm() { ignored_platforms: row.ignored_platforms ? JSON.stringify(row.ignored_platforms) : undefined, + included_platforms: row.included_platforms + ? JSON.stringify(row.included_platforms) + : undefined, ignored_recurring_series: row.ignored_recurring_series ? JSON.stringify(row.ignored_recurring_series) : undefined, diff --git a/apps/desktop/src/settings/general/notification-app-options.test.ts b/apps/desktop/src/settings/general/notification-app-options.test.ts index 6a203b8d68..a81526b5d9 100644 --- a/apps/desktop/src/settings/general/notification-app-options.test.ts +++ b/apps/desktop/src/settings/general/notification-app-options.test.ts @@ -1,34 +1,71 @@ import { describe, expect, test } from "vitest"; -import { getIgnoredAppOptions } from "./notification-app-options"; +import { + getEffectiveIgnoredPlatformIds, + getMicDetectionAppOptions, +} from "./notification-app-options"; -describe("getIgnoredAppOptions", () => { - test("returns installed app matches for partial searches", () => { - const options = getIgnoredAppOptions({ - allInstalledApps: [ - { id: "us.zoom.xos", name: "Zoom Workplace" }, - { id: "com.tinyspeck.slackmacgap", name: "Slack" }, - ], +describe("getMicDetectionAppOptions", () => { + test("shows default ignored apps as includable matches", () => { + const options = getMicDetectionAppOptions({ + allInstalledApps: [{ id: "com.microsoft.VSCode", name: "VS Code" }], ignoredPlatforms: [], - inputValue: "zoom", - defaultIgnoredBundleIds: [], + includedPlatforms: [], + inputValue: "code", + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], }); - expect(options).toEqual([{ id: "us.zoom.xos", name: "Zoom Workplace" }]); + expect(options).toEqual([ + { + id: "com.microsoft.VSCode", + name: "VS Code", + action: "include", + isDefaultIgnored: true, + }, + ]); }); - test("filters out already ignored and default ignored apps", () => { - const options = getIgnoredAppOptions({ - allInstalledApps: [ - { id: "us.zoom.xos", name: "Zoom Workplace" }, - { id: "com.tinyspeck.slackmacgap", name: "Slack" }, - { id: "com.openai.chat", name: "ChatGPT" }, - ], - ignoredPlatforms: ["com.tinyspeck.slackmacgap"], - inputValue: "", - defaultIgnoredBundleIds: ["com.openai.chat"], + test("shows explicitly included default apps as excludable again", () => { + const options = getMicDetectionAppOptions({ + allInstalledApps: [{ id: "com.microsoft.VSCode", name: "VS Code" }], + ignoredPlatforms: [], + includedPlatforms: ["com.microsoft.VSCode"], + inputValue: "code", + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], }); - expect(options).toEqual([{ id: "us.zoom.xos", name: "Zoom Workplace" }]); + expect(options).toEqual([ + { + id: "com.microsoft.VSCode", + name: "VS Code", + action: "exclude", + isDefaultIgnored: true, + }, + ]); + }); +}); + +describe("getEffectiveIgnoredPlatformIds", () => { + test("includes installed default ignored apps unless explicitly included", () => { + expect( + getEffectiveIgnoredPlatformIds({ + installedApps: [ + { id: "com.microsoft.VSCode", name: "VS Code" }, + { id: "us.zoom.xos", name: "Zoom Workplace" }, + ], + ignoredPlatforms: [], + includedPlatforms: [], + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], + }), + ).toEqual(["com.microsoft.VSCode"]); + + expect( + getEffectiveIgnoredPlatformIds({ + installedApps: [{ id: "com.microsoft.VSCode", name: "VS Code" }], + ignoredPlatforms: [], + includedPlatforms: ["com.microsoft.VSCode"], + defaultIgnoredBundleIds: ["com.microsoft.VSCode"], + }), + ).toEqual([]); }); }); diff --git a/apps/desktop/src/settings/general/notification-app-options.ts b/apps/desktop/src/settings/general/notification-app-options.ts index 41330ce440..84b45925f2 100644 --- a/apps/desktop/src/settings/general/notification-app-options.ts +++ b/apps/desktop/src/settings/general/notification-app-options.ts @@ -1,22 +1,88 @@ import type { InstalledApp } from "@hypr/plugin-detect"; -export function getIgnoredAppOptions({ +function isEffectivelyIgnored({ + bundleId, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, +}: { + bundleId: string; + ignoredPlatforms: string[]; + includedPlatforms: string[]; + defaultIgnoredBundleIds: string[] | undefined; +}) { + const isDefaultIgnored = defaultIgnoredBundleIds?.includes(bundleId) ?? false; + const isIncluded = includedPlatforms.includes(bundleId); + const isUserIgnored = ignoredPlatforms.includes(bundleId); + + return isUserIgnored || (isDefaultIgnored && !isIncluded); +} + +export function getMicDetectionAppOptions({ allInstalledApps, ignoredPlatforms, + includedPlatforms, inputValue, defaultIgnoredBundleIds, }: { allInstalledApps: InstalledApp[] | undefined; ignoredPlatforms: string[]; + includedPlatforms: string[]; inputValue: string; defaultIgnoredBundleIds: string[] | undefined; }) { - return (allInstalledApps ?? []).filter((app) => { - const matchesSearch = app.name - .toLowerCase() - .includes(inputValue.toLowerCase()); - const notAlreadyAdded = !ignoredPlatforms.includes(app.id); - const notDefaultIgnored = !(defaultIgnoredBundleIds ?? []).includes(app.id); - return matchesSearch && notAlreadyAdded && notDefaultIgnored; - }); + return (allInstalledApps ?? []) + .filter((app) => + app.name.toLowerCase().includes(inputValue.trim().toLowerCase()), + ) + .map((app) => { + const isDefaultIgnored = + defaultIgnoredBundleIds?.includes(app.id) ?? false; + const isIgnored = isEffectivelyIgnored({ + bundleId: app.id, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, + }); + + return { + ...app, + action: isIgnored ? "include" : "exclude", + isDefaultIgnored, + }; + }); +} + +export function getEffectiveIgnoredPlatformIds({ + installedApps, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, +}: { + installedApps: InstalledApp[] | undefined; + ignoredPlatforms: string[]; + includedPlatforms: string[]; + defaultIgnoredBundleIds: string[] | undefined; +}) { + const installedAppIds = new Set((installedApps ?? []).map((app) => app.id)); + const bundleIds = new Set(ignoredPlatforms); + + for (const bundleId of defaultIgnoredBundleIds ?? []) { + if (!installedAppIds.has(bundleId)) { + continue; + } + + if ( + isEffectivelyIgnored({ + bundleId, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, + }) + ) { + bundleIds.add(bundleId); + } + } + + return [...bundleIds]; } diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 4801adbd62..99ac578783 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -21,7 +21,10 @@ import { import { Switch } from "@hypr/ui/components/ui/switch"; import { cn } from "@hypr/utils"; -import { getIgnoredAppOptions } from "./notification-app-options"; +import { + getEffectiveIgnoredPlatformIds, + getMicDetectionAppOptions, +} from "./notification-app-options"; import { useConfigValues } from "~/shared/config"; import * as settings from "~/store/tinybase/store/settings"; @@ -38,6 +41,7 @@ export function NotificationSettingsView() { "notification_detect", "respect_dnd", "ignored_platforms", + "included_platforms", "mic_active_threshold", ] as const); @@ -106,6 +110,13 @@ export function NotificationSettingsView() { settings.STORE_ID, ); + const handleSetIncludedPlatforms = settings.UI.useSetValueCallback( + "included_platforms", + (value: string) => value, + [], + settings.STORE_ID, + ); + const handleSetMicActiveThreshold = settings.UI.useSetValueCallback( "mic_active_threshold", (value: number) => value, @@ -119,6 +130,7 @@ export function NotificationSettingsView() { notification_detect: configs.notification_detect, respect_dnd: configs.respect_dnd, ignored_platforms: configs.ignored_platforms, + included_platforms: configs.included_platforms, mic_active_threshold: configs.mic_active_threshold, }, listeners: { @@ -131,6 +143,7 @@ export function NotificationSettingsView() { handleSetNotificationDetect(value.notification_detect); handleSetRespectDnd(value.respect_dnd); handleSetIgnoredPlatforms(JSON.stringify(value.ignored_platforms)); + handleSetIncludedPlatforms(JSON.stringify(value.included_platforms)); handleSetMicActiveThreshold(value.mic_active_threshold); }, }); @@ -138,44 +151,59 @@ export function NotificationSettingsView() { const anyNotificationEnabled = configs.notification_event || configs.notification_detect; const ignoredPlatforms = form.getFieldValue("ignored_platforms"); + const includedPlatforms = form.getFieldValue("included_platforms"); - const dropdownOptions = getIgnoredAppOptions({ + const dropdownOptions = getMicDetectionAppOptions({ allInstalledApps, ignoredPlatforms, + includedPlatforms, inputValue, defaultIgnoredBundleIds, }); + const effectiveIgnoredPlatformIds = getEffectiveIgnoredPlatformIds({ + installedApps: allInstalledApps, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, + }).sort((left, right) => + bundleIdToName(left).localeCompare(bundleIdToName(right)), + ); - const handleAddIgnoredApp = (bundleId: string) => { - if ( - !bundleId || - ignoredPlatforms.includes(bundleId) || - isDefaultIgnored(bundleId) - ) { + const handleToggleIgnoredApp = (bundleId: string) => { + if (!bundleId) { return; } - form.setFieldValue("ignored_platforms", [...ignoredPlatforms, bundleId]); + const defaultIgnored = isDefaultIgnored(bundleId); + const isCurrentlyIgnored = + ignoredPlatforms.includes(bundleId) || + (defaultIgnored && !includedPlatforms.includes(bundleId)); + + const nextIgnoredPlatforms = isCurrentlyIgnored + ? ignoredPlatforms.filter((appId: string) => appId !== bundleId) + : defaultIgnored + ? ignoredPlatforms.filter((appId: string) => appId !== bundleId) + : [...ignoredPlatforms, bundleId]; + const nextIncludedPlatforms = isCurrentlyIgnored + ? defaultIgnored && !includedPlatforms.includes(bundleId) + ? [...includedPlatforms, bundleId] + : includedPlatforms + : includedPlatforms.filter((appId: string) => appId !== bundleId); + + form.setFieldValue("ignored_platforms", nextIgnoredPlatforms); + form.setFieldValue("included_platforms", nextIncludedPlatforms); void form.handleSubmit(); setInputValue(""); setShowDropdown(false); setSelectedIndex(0); }; - const handleRemoveIgnoredApp = (bundleId: string) => { - const updated = ignoredPlatforms.filter( - (appId: string) => appId !== bundleId, - ); - form.setFieldValue("ignored_platforms", updated); - void form.handleSubmit(); - }; - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && inputValue.trim()) { e.preventDefault(); const selectedApp = dropdownOptions[selectedIndex]; if (selectedApp) { - handleAddIgnoredApp(selectedApp.id); + handleToggleIgnoredApp(selectedApp.id); } } else if (e.key === "ArrowDown") { e.preventDefault(); @@ -191,12 +219,11 @@ export function NotificationSettingsView() { } else if ( e.key === "Backspace" && !inputValue && - ignoredPlatforms.length > 0 + effectiveIgnoredPlatformIds.length > 0 ) { - const lastBundleId = ignoredPlatforms[ignoredPlatforms.length - 1]; - if (!isDefaultIgnored(lastBundleId)) { - handleRemoveIgnoredApp(lastBundleId); - } + const lastBundleId = + effectiveIgnoredPlatformIds[effectiveIgnoredPlatformIds.length - 1]; + handleToggleIgnoredApp(lastBundleId); } }; @@ -296,7 +323,8 @@ export function NotificationSettingsView() { Exclude apps from detection

- These apps will not trigger detection. + Search installed apps to exclude them, or click an excluded + app to include it again.

@@ -304,7 +332,7 @@ export function NotificationSettingsView() { className="flex min-h-[38px] w-full cursor-text flex-wrap items-center gap-2 rounded-md border p-2" onClick={() => inputRef.current?.focus()} > - {ignoredPlatforms.map((bundleId: string) => { + {effectiveIgnoredPlatformIds.map((bundleId: string) => { const isDefault = isDefaultIgnored(bundleId); return ( )} - {!isDefault && ( - - )} + ); })} @@ -343,8 +369,8 @@ export function NotificationSettingsView() { type="text" className="placeholder:text-muted-foreground min-w-[120px] flex-1 bg-transparent text-sm outline-hidden" placeholder={ - ignoredPlatforms.length === 0 - ? "Type to add apps..." + effectiveIgnoredPlatformIds.length === 0 + ? "Type to search installed apps..." : "" } value={inputValue} @@ -362,15 +388,20 @@ export function NotificationSettingsView() { key={app.id} type="button" className={cn([ - "w-full px-3 py-1.5 text-left text-sm transition-colors", + "flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors", "hover:bg-accent hover:text-accent-foreground", selectedIndex === index && "bg-accent text-accent-foreground", ])} - onClick={() => handleAddIgnoredApp(app.id)} + onClick={() => handleToggleIgnoredApp(app.id)} onMouseEnter={() => setSelectedIndex(index)} > - {app.name} + {app.name} + + {app.action === "include" + ? "Include" + : "Exclude"} + ); })} diff --git a/apps/desktop/src/shared/config/index.ts b/apps/desktop/src/shared/config/index.ts index f066f36211..a9ec8179c8 100644 --- a/apps/desktop/src/shared/config/index.ts +++ b/apps/desktop/src/shared/config/index.ts @@ -1,7 +1,10 @@ import * as settings from "~/store/tinybase/store/settings"; import type { SettingsValueKey } from "~/store/tinybase/store/settings"; -type JsonParsedKeys = "spoken_languages" | "ignored_platforms"; +type JsonParsedKeys = + | "spoken_languages" + | "ignored_platforms" + | "included_platforms"; type ConfigValueType = K extends JsonParsedKeys ? string[] @@ -28,7 +31,11 @@ export function useConfigValue( const defaultValue = "default" in mapping ? mapping.default : undefined; if (storedValue !== undefined) { - if (key === "ignored_platforms" || key === "spoken_languages") { + if ( + key === "ignored_platforms" || + key === "included_platforms" || + key === "spoken_languages" + ) { return tryParseJSON( storedValue, JSON.parse(defaultValue as string), @@ -53,7 +60,11 @@ export function useConfigValues( const defaultValue = "default" in mapping ? mapping.default : undefined; if (storedValue !== undefined) { - if (key === "ignored_platforms" || key === "spoken_languages") { + if ( + key === "ignored_platforms" || + key === "included_platforms" || + key === "spoken_languages" + ) { result[key] = tryParseJSON( storedValue, defaultValue, diff --git a/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts b/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts index 34a7f46b5a..315ccea7be 100644 --- a/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts +++ b/apps/desktop/src/store/tinybase/persister/settings/persister.test.ts @@ -94,6 +94,7 @@ describe("settingsPersister roundtrip", () => { detect: false, respect_dnd: true, ignored_platforms: ["zoom", "slack"], + included_platforms: ["code"], }, general: { autostart: true, @@ -154,6 +155,7 @@ describe("settingsPersister roundtrip", () => { notification_detect: false, respect_dnd: true, ignored_platforms: '["zoom"]', + included_platforms: '["code"]', autostart: true, save_recordings: false, telemetry_consent: false, @@ -289,6 +291,7 @@ describe("settingsPersister roundtrip", () => { }, notification: { ignored_platforms: '["zoom"]', + included_platforms: '["code"]', }, }; @@ -305,6 +308,7 @@ describe("settingsPersister roundtrip", () => { }); expect(result.notification).toEqual({ ignored_platforms: ["zoom"], + included_platforms: ["code"], }); }); @@ -315,6 +319,7 @@ describe("settingsPersister roundtrip", () => { }, notification: { ignored_platforms: "zoom,slack", + included_platforms: "code,terminal", }, }; @@ -331,6 +336,7 @@ describe("settingsPersister roundtrip", () => { }); expect(result.notification).toEqual({ ignored_platforms: ["zoom", "slack"], + included_platforms: ["code", "terminal"], }); }); diff --git a/apps/desktop/src/store/tinybase/persister/settings/transform.ts b/apps/desktop/src/store/tinybase/persister/settings/transform.ts index 387c6cc793..afca9c0575 100644 --- a/apps/desktop/src/store/tinybase/persister/settings/transform.ts +++ b/apps/desktop/src/store/tinybase/persister/settings/transform.ts @@ -9,6 +9,7 @@ type ProviderRow = { type: "llm" | "stt"; base_url: string; api_key: string }; const JSON_ARRAY_FIELDS = new Set([ "spoken_languages", "ignored_platforms", + "included_platforms", "ignored_recurring_series", ]); diff --git a/apps/desktop/src/store/tinybase/store/settings.ts b/apps/desktop/src/store/tinybase/store/settings.ts index e45394d126..0a6be51168 100644 --- a/apps/desktop/src/store/tinybase/store/settings.ts +++ b/apps/desktop/src/store/tinybase/store/settings.ts @@ -71,6 +71,11 @@ export const SETTINGS_MAPPING = { path: ["notification", "ignored_platforms"], default: "[]" as string, }, + included_platforms: { + type: "string", + path: ["notification", "included_platforms"], + default: "[]" as string, + }, mic_active_threshold: { type: "number", path: ["notification", "mic_active_threshold"], @@ -279,6 +284,12 @@ const SETTINGS_LISTENERS: SettingsListeners = { detectCommands.setIgnoredBundleIds(parsed).catch(console.error); } catch {} }, + included_platforms: (_store, newValue) => { + try { + const parsed = JSON.parse(newValue); + detectCommands.setIncludedBundleIds(parsed).catch(console.error); + } catch {} + }, mic_active_threshold: (_store, newValue) => { detectCommands.setMicActiveThreshold(newValue).catch(console.error); }, diff --git a/crates/db-user/src/config_types.rs b/crates/db-user/src/config_types.rs index 2110e48c8b..994b9ab1d7 100644 --- a/crates/db-user/src/config_types.rs +++ b/crates/db-user/src/config_types.rs @@ -73,6 +73,8 @@ user_common_derives! { pub auto: bool, #[serde(rename = "ignoredPlatforms")] pub ignored_platforms: Option>, + #[serde(rename = "includedPlatforms")] + pub included_platforms: Option>, } } @@ -82,6 +84,7 @@ impl Default for ConfigNotification { before: true, auto: true, ignored_platforms: None, + included_platforms: None, } } } diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index 8f36b532e1..37f35e81bc 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -171,6 +171,7 @@ export const valueSchemaForTinybase = { ai_language: { type: "string" }, spoken_languages: { type: "string" }, ignored_platforms: { type: "string" }, + included_platforms: { type: "string" }, ignored_events: { type: "string" }, ignored_recurring_series: { type: "string" }, current_llm_provider: { type: "string" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index cba1072aa6..c463e77bf4 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -266,6 +266,7 @@ export const generalSchema = z.object({ ai_language: z.string().default("en"), spoken_languages: jsonObject(z.array(z.string()).default(["en"])), ignored_platforms: jsonObject(z.array(z.string()).default([])), + included_platforms: jsonObject(z.array(z.string()).default([])), ignored_events: jsonObject(z.array(ignoredEventEntrySchema).default([])), ignored_recurring_series: jsonObject( z.array(ignoredRecurringSeriesEntrySchema).default([]), diff --git a/plugins/detect/js/bindings.gen.ts b/plugins/detect/js/bindings.gen.ts index cdb85d70e7..628caf20b4 100644 --- a/plugins/detect/js/bindings.gen.ts +++ b/plugins/detect/js/bindings.gen.ts @@ -38,6 +38,14 @@ async setIgnoredBundleIds(bundleIds: string[]) : Promise> { else return { status: "error", error: e as any }; } }, +async setIncludedBundleIds(bundleIds: string[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:detect|set_included_bundle_ids", { bundleIds }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async listDefaultIgnoredBundleIds() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("plugin:detect|list_default_ignored_bundle_ids") }; diff --git a/plugins/detect/src/commands.rs b/plugins/detect/src/commands.rs index ca1f33fb17..4994fe1fd8 100644 --- a/plugins/detect/src/commands.rs +++ b/plugins/detect/src/commands.rs @@ -34,6 +34,16 @@ pub(crate) async fn set_ignored_bundle_ids( Ok(()) } +#[tauri::command] +#[specta::specta] +pub(crate) async fn set_included_bundle_ids( + app: tauri::AppHandle, + bundle_ids: Vec, +) -> Result<(), String> { + app.detect().set_included_bundle_ids(bundle_ids); + Ok(()) +} + #[tauri::command] #[specta::specta] pub(crate) async fn set_respect_do_not_disturb( diff --git a/plugins/detect/src/ext.rs b/plugins/detect/src/ext.rs index 4ed3d09799..decede31f2 100644 --- a/plugins/detect/src/ext.rs +++ b/plugins/detect/src/ext.rs @@ -25,6 +25,22 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { state_guard.policy.user_ignored_bundle_ids = bundle_ids.into_iter().collect(); } + pub fn set_included_bundle_ids(&self, bundle_ids: Vec) { + let state = self.manager.state::(); + let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); + let next_ids = bundle_ids + .into_iter() + .collect::>(); + + for id in crate::policy::default_ignored_bundle_ids() { + if !next_ids.contains(&id) { + state_guard.mic_usage_tracker.cancel_app(&id); + } + } + + state_guard.policy.user_included_bundle_ids = next_ids; + } + pub fn set_respect_do_not_disturb(&self, enabled: bool) { let state = self.manager.state::(); let mut state_guard = state.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/plugins/detect/src/lib.rs b/plugins/detect/src/lib.rs index 0e955049ac..a30c40baab 100644 --- a/plugins/detect/src/lib.rs +++ b/plugins/detect/src/lib.rs @@ -64,6 +64,7 @@ fn make_specta_builder() -> tauri_specta::Builder { commands::list_mic_using_applications::, commands::set_respect_do_not_disturb::, commands::set_ignored_bundle_ids::, + commands::set_included_bundle_ids::, commands::list_default_ignored_bundle_ids::, commands::get_preferred_languages::, commands::get_current_locale_identifier::, diff --git a/plugins/detect/src/policy.rs b/plugins/detect/src/policy.rs index 3797908d5b..260e38853d 100644 --- a/plugins/detect/src/policy.rs +++ b/plugins/detect/src/policy.rs @@ -110,12 +110,17 @@ pub struct MicNotificationPolicy { pub respect_dnd: bool, pub ignored_categories: Vec, pub user_ignored_bundle_ids: HashSet, + pub user_included_bundle_ids: HashSet, } impl MicNotificationPolicy { pub fn should_track_app(&self, app_id: &str) -> bool { - AppCategory::find_category(app_id).is_none() - && !self.user_ignored_bundle_ids.contains(app_id) + if self.user_ignored_bundle_ids.contains(app_id) { + return false; + } + + self.user_included_bundle_ids.contains(app_id) + || AppCategory::find_category(app_id).is_none() } fn filter_apps( @@ -136,8 +141,12 @@ impl MicNotificationPolicy { let filtered_apps: Vec<_> = apps .iter() .filter(|app| { - !self.user_ignored_bundle_ids.contains(&app.id) - && !ignored_from_categories.contains(app.id.as_str()) + if self.user_ignored_bundle_ids.contains(&app.id) { + return false; + } + + self.user_included_bundle_ids.contains(&app.id) + || !ignored_from_categories.contains(app.id.as_str()) }) .cloned() .collect(); @@ -174,6 +183,7 @@ impl Default for MicNotificationPolicy { respect_dnd: false, ignored_categories: AppCategory::all().to_vec(), user_ignored_bundle_ids: HashSet::new(), + user_included_bundle_ids: HashSet::new(), } } } @@ -296,6 +306,15 @@ mod tests { assert!(!policy.should_track_app("us.zoom.xos")); } + #[test] + fn test_should_track_user_included_categorized_app() { + let policy = MicNotificationPolicy { + user_included_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + ..Default::default() + }; + assert!(policy.should_track_app("com.microsoft.VSCode")); + } + #[test] fn test_user_ignored_does_not_affect_other_apps() { let policy = MicNotificationPolicy { @@ -352,6 +371,23 @@ mod tests { ); } + #[test] + fn test_evaluate_keeps_user_included_default_app() { + let policy = MicNotificationPolicy { + user_included_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + ..Default::default() + }; + let apps = vec![app("com.microsoft.VSCode")]; + let ctx = PolicyContext { + apps: &apps, + is_dnd: false, + event_type: MicEventType::Started, + }; + let result = policy.evaluate(&ctx).unwrap(); + assert_eq!(result.filtered_apps.len(), 1); + assert_eq!(result.filtered_apps[0].id, "com.microsoft.VSCode"); + } + #[test] fn test_evaluate_mixed_apps_keeps_unknown_only() { let policy = MicNotificationPolicy::default(); From 7169a2160fbdc6964380ec79245295b7c4878e9e Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 10 Mar 2026 17:01:17 +0900 Subject: [PATCH 3/8] refactor: hide ignored apps from notification dropdown Filter out already ignored apps from the dropdown options instead of showing them with an "include" action. This simplifies the interface by only displaying apps that can actually be excluded, making the user experience more intuitive. Remove action labels and isDefaultIgnored properties from dropdown items since they are no longer needed. Update helper text to reflect the new behavior where only non-ignored apps appear in search results. --- .../general/notification-app-options.test.ts | 20 ++----------- .../general/notification-app-options.ts | 30 +++++++------------ .../src/settings/general/notification.tsx | 9 ++---- 3 files changed, 16 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/settings/general/notification-app-options.test.ts b/apps/desktop/src/settings/general/notification-app-options.test.ts index a81526b5d9..e556553a6a 100644 --- a/apps/desktop/src/settings/general/notification-app-options.test.ts +++ b/apps/desktop/src/settings/general/notification-app-options.test.ts @@ -6,7 +6,7 @@ import { } from "./notification-app-options"; describe("getMicDetectionAppOptions", () => { - test("shows default ignored apps as includable matches", () => { + test("hides default ignored apps from dropdown matches", () => { const options = getMicDetectionAppOptions({ allInstalledApps: [{ id: "com.microsoft.VSCode", name: "VS Code" }], ignoredPlatforms: [], @@ -15,14 +15,7 @@ describe("getMicDetectionAppOptions", () => { defaultIgnoredBundleIds: ["com.microsoft.VSCode"], }); - expect(options).toEqual([ - { - id: "com.microsoft.VSCode", - name: "VS Code", - action: "include", - isDefaultIgnored: true, - }, - ]); + expect(options).toEqual([]); }); test("shows explicitly included default apps as excludable again", () => { @@ -34,14 +27,7 @@ describe("getMicDetectionAppOptions", () => { defaultIgnoredBundleIds: ["com.microsoft.VSCode"], }); - expect(options).toEqual([ - { - id: "com.microsoft.VSCode", - name: "VS Code", - action: "exclude", - isDefaultIgnored: true, - }, - ]); + expect(options).toEqual([{ id: "com.microsoft.VSCode", name: "VS Code" }]); }); }); diff --git a/apps/desktop/src/settings/general/notification-app-options.ts b/apps/desktop/src/settings/general/notification-app-options.ts index 84b45925f2..4521097006 100644 --- a/apps/desktop/src/settings/general/notification-app-options.ts +++ b/apps/desktop/src/settings/general/notification-app-options.ts @@ -31,26 +31,18 @@ export function getMicDetectionAppOptions({ inputValue: string; defaultIgnoredBundleIds: string[] | undefined; }) { - return (allInstalledApps ?? []) - .filter((app) => - app.name.toLowerCase().includes(inputValue.trim().toLowerCase()), - ) - .map((app) => { - const isDefaultIgnored = - defaultIgnoredBundleIds?.includes(app.id) ?? false; - const isIgnored = isEffectivelyIgnored({ - bundleId: app.id, - ignoredPlatforms, - includedPlatforms, - defaultIgnoredBundleIds, - }); - - return { - ...app, - action: isIgnored ? "include" : "exclude", - isDefaultIgnored, - }; + return (allInstalledApps ?? []).filter((app) => { + const matchesSearch = app.name + .toLowerCase() + .includes(inputValue.trim().toLowerCase()); + const isIgnored = isEffectivelyIgnored({ + bundleId: app.id, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, }); + return matchesSearch && !isIgnored; + }); } export function getEffectiveIgnoredPlatformIds({ diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 99ac578783..038046b8ec 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -323,8 +323,8 @@ export function NotificationSettingsView() { Exclude apps from detection

- Search installed apps to exclude them, or click an excluded - app to include it again. + Search installed apps to exclude them. Click an excluded app + to include it again.

@@ -397,11 +397,6 @@ export function NotificationSettingsView() { onMouseEnter={() => setSelectedIndex(index)} > {app.name} - - {app.action === "include" - ? "Include" - : "Exclude"} - ); })} From db6926d01852dc4f91d8185f9a80a9f3c07f6cf8 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 10 Mar 2026 17:14:56 +0900 Subject: [PATCH 4/8] refactor(desktop): replace custom autocomplete with shadcn Command component Migrate notification settings app selection from custom dropdown implementation to shadcn's Command/Popover components for better UX and maintainability. Remove manual keyboard handling and click-outside logic in favor of built-in Command component functionality. --- .../src/settings/general/notification.tsx | 236 ++++++++---------- 1 file changed, 108 insertions(+), 128 deletions(-) diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 038046b8ec..82942c2f19 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -1,7 +1,7 @@ import { useForm } from "@tanstack/react-form"; import { useQuery } from "@tanstack/react-query"; import { X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { commands as detectCommands, @@ -11,6 +11,19 @@ import { import { commands as notificationCommands } from "@hypr/plugin-notification"; import { Badge } from "@hypr/ui/components/ui/badge"; import { Button } from "@hypr/ui/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@hypr/ui/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@hypr/ui/components/ui/popover"; import { Select, SelectContent, @@ -30,11 +43,8 @@ import { useConfigValues } from "~/shared/config"; import * as settings from "~/store/tinybase/store/settings"; export function NotificationSettingsView() { - const [inputValue, setInputValue] = useState(""); - const [showDropdown, setShowDropdown] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(0); - const inputRef = useRef(null); - const containerRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const configs = useConfigValues([ "notification_event", @@ -157,7 +167,7 @@ export function NotificationSettingsView() { allInstalledApps, ignoredPlatforms, includedPlatforms, - inputValue, + inputValue: searchQuery, defaultIgnoredBundleIds, }); const effectiveIgnoredPlatformIds = getEffectiveIgnoredPlatformIds({ @@ -193,60 +203,10 @@ export function NotificationSettingsView() { form.setFieldValue("ignored_platforms", nextIgnoredPlatforms); form.setFieldValue("included_platforms", nextIncludedPlatforms); void form.handleSubmit(); - setInputValue(""); - setShowDropdown(false); - setSelectedIndex(0); + setSearchOpen(false); + setSearchQuery(""); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && inputValue.trim()) { - e.preventDefault(); - const selectedApp = dropdownOptions[selectedIndex]; - if (selectedApp) { - handleToggleIgnoredApp(selectedApp.id); - } - } else if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedIndex((prev) => - prev < dropdownOptions.length - 1 ? prev + 1 : prev, - ); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - } else if (e.key === "Escape") { - setShowDropdown(false); - setSelectedIndex(0); - } else if ( - e.key === "Backspace" && - !inputValue && - effectiveIgnoredPlatformIds.length > 0 - ) { - const lastBundleId = - effectiveIgnoredPlatformIds[effectiveIgnoredPlatformIds.length - 1]; - handleToggleIgnoredApp(lastBundleId); - } - }; - - const handleInputChange = (value: string) => { - setInputValue(value); - setShowDropdown(true); - setSelectedIndex(0); - }; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setShowDropdown(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - return (
@@ -327,82 +287,102 @@ export function NotificationSettingsView() { to include it again.

-
-
inputRef.current?.focus()} - > - {effectiveIgnoredPlatformIds.map((bundleId: string) => { - const isDefault = isDefaultIgnored(bundleId); - return ( - - {bundleIdToName(bundleId)} - {isDefault && ( - - (default) - - )} - - - ); - })} - handleInputChange(e.target.value)} - onKeyDown={handleKeyDown} - onFocus={() => setShowDropdown(true)} - /> -
- {showDropdown && dropdownOptions.length > 0 && ( -
-
- {dropdownOptions.map((app, index) => { +
+ + +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSearchOpen(true); + } + }} + > + {effectiveIgnoredPlatformIds.map((bundleId: string) => { + const isDefault = isDefaultIgnored(bundleId); return ( - + {bundleIdToName(bundleId)} + {isDefault && ( + + (default) + + )} + + ); })} + + Search installed apps... +
-
- )} + + + + + +
+ No apps found. +
+
+ + + {dropdownOptions.map((app) => ( + handleToggleIgnoredApp(app.id)} + className={cn([ + "cursor-pointer", + "hover:bg-neutral-200! focus:bg-neutral-200! aria-selected:bg-transparent", + ])} + > + + {app.name} + + + ))} + + +
+
+
)} From ec24cc5adf84c5100ca2ce212a9ded5a9e0fb5f6 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Tue, 10 Mar 2026 17:20:27 +0900 Subject: [PATCH 5/8] packing logic Add width-aware ordering algorithm for ignored platform ID chips that optimizes layout by filling rows efficiently. Implement container width tracking using ResizeObserver to dynamically adjust chip ordering based on available space. This improves visual organization of ignored apps by minimizing gaps and creating more balanced rows when chips wrap to multiple lines. --- .../general/notification-app-options.test.ts | 20 +++++ .../general/notification-app-options.ts | 81 +++++++++++++++++++ .../src/settings/general/notification.tsx | 45 ++++++++--- 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/settings/general/notification-app-options.test.ts b/apps/desktop/src/settings/general/notification-app-options.test.ts index e556553a6a..af539119b5 100644 --- a/apps/desktop/src/settings/general/notification-app-options.test.ts +++ b/apps/desktop/src/settings/general/notification-app-options.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest"; import { getEffectiveIgnoredPlatformIds, getMicDetectionAppOptions, + orderIgnoredPlatformIdsForWrap, } from "./notification-app-options"; describe("getMicDetectionAppOptions", () => { @@ -55,3 +56,22 @@ describe("getEffectiveIgnoredPlatformIds", () => { ).toEqual([]); }); }); + +describe("orderIgnoredPlatformIdsForWrap", () => { + test("reorders chips to better fill each row", () => { + const ordered = orderIgnoredPlatformIdsForWrap({ + bundleIds: ["a", "b", "c", "d"], + availableWidth: 214, + bundleIdToName: (bundleId) => + ({ + a: "ABCDEFGHIJ", + b: "KLMNOPQRST", + c: "Tiny", + d: "Mini", + })[bundleId] ?? bundleId, + isDefaultIgnored: () => false, + }); + + expect(ordered).toEqual(["a", "d", "b", "c"]); + }); +}); diff --git a/apps/desktop/src/settings/general/notification-app-options.ts b/apps/desktop/src/settings/general/notification-app-options.ts index 4521097006..e116a8b4aa 100644 --- a/apps/desktop/src/settings/general/notification-app-options.ts +++ b/apps/desktop/src/settings/general/notification-app-options.ts @@ -78,3 +78,84 @@ export function getEffectiveIgnoredPlatformIds({ return [...bundleIds]; } + +function estimateIgnoredChipWidth({ + label, + isDefaultIgnored, +}: { + label: string; + isDefaultIgnored: boolean; +}) { + const labelWidth = label.length * 9; + const defaultWidth = isDefaultIgnored ? 78 : 0; + + return labelWidth + defaultWidth + 44; +} + +export function orderIgnoredPlatformIdsForWrap({ + bundleIds, + availableWidth, + bundleIdToName, + isDefaultIgnored, +}: { + bundleIds: string[]; + availableWidth: number | null; + bundleIdToName: (bundleId: string) => string; + isDefaultIgnored: (bundleId: string) => boolean; +}) { + const items = bundleIds.map((bundleId) => ({ + bundleId, + label: bundleIdToName(bundleId), + estimatedWidth: estimateIgnoredChipWidth({ + label: bundleIdToName(bundleId), + isDefaultIgnored: isDefaultIgnored(bundleId), + }), + })); + + if (!availableWidth || availableWidth <= 0) { + return items + .sort( + (left, right) => + right.estimatedWidth - left.estimatedWidth || + left.label.localeCompare(right.label), + ) + .map((item) => item.bundleId); + } + + const rows: { items: typeof items; remainingWidth: number }[] = []; + + for (const item of items.sort( + (left, right) => + right.estimatedWidth - left.estimatedWidth || + left.label.localeCompare(right.label), + )) { + let bestRow: { items: typeof items; remainingWidth: number } | undefined; + + for (const row of rows) { + if (row.remainingWidth < item.estimatedWidth) { + continue; + } + + if ( + !bestRow || + row.remainingWidth - item.estimatedWidth < + bestRow.remainingWidth - item.estimatedWidth + ) { + bestRow = row; + } + } + + if (bestRow) { + bestRow.items.push(item); + bestRow.remainingWidth -= item.estimatedWidth; + continue; + } + + rows.push({ + items: [item], + remainingWidth: Math.max(0, availableWidth - item.estimatedWidth), + }); + } + + return rows.flatMap((row) => row.items.map((item) => item.bundleId)); +} diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 82942c2f19..666bbdd313 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -1,7 +1,7 @@ import { useForm } from "@tanstack/react-form"; import { useQuery } from "@tanstack/react-query"; import { X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { commands as detectCommands, @@ -37,6 +37,7 @@ import { cn } from "@hypr/utils"; import { getEffectiveIgnoredPlatformIds, getMicDetectionAppOptions, + orderIgnoredPlatformIdsForWrap, } from "./notification-app-options"; import { useConfigValues } from "~/shared/config"; @@ -45,6 +46,10 @@ import * as settings from "~/store/tinybase/store/settings"; export function NotificationSettingsView() { const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const chipContainerRef = useRef(null); + const [chipContainerWidth, setChipContainerWidth] = useState( + null, + ); const configs = useConfigValues([ "notification_event", @@ -170,14 +175,35 @@ export function NotificationSettingsView() { inputValue: searchQuery, defaultIgnoredBundleIds, }); - const effectiveIgnoredPlatformIds = getEffectiveIgnoredPlatformIds({ - installedApps: allInstalledApps, - ignoredPlatforms, - includedPlatforms, - defaultIgnoredBundleIds, - }).sort((left, right) => - bundleIdToName(left).localeCompare(bundleIdToName(right)), - ); + const effectiveIgnoredPlatformIds = orderIgnoredPlatformIdsForWrap({ + bundleIds: getEffectiveIgnoredPlatformIds({ + installedApps: allInstalledApps, + ignoredPlatforms, + includedPlatforms, + defaultIgnoredBundleIds, + }), + availableWidth: chipContainerWidth, + bundleIdToName, + isDefaultIgnored, + }); + + useEffect(() => { + const element = chipContainerRef.current; + if (!element) { + return; + } + + const updateWidth = () => { + setChipContainerWidth(Math.max(0, element.clientWidth - 16)); + }; + + updateWidth(); + + const observer = new ResizeObserver(() => updateWidth()); + observer.observe(element); + + return () => observer.disconnect(); + }, []); const handleToggleIgnoredApp = (bundleId: string) => { if (!bundleId) { @@ -291,6 +317,7 @@ export function NotificationSettingsView() {
Date: Tue, 10 Mar 2026 08:39:02 +0000 Subject: [PATCH 6/8] fix: wrap resolve_base test with env guard to prevent race condition Co-Authored-By: John --- crates/storage/src/vault/path.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/storage/src/vault/path.rs b/crates/storage/src/vault/path.rs index 818376cd5b..514a973160 100644 --- a/crates/storage/src/vault/path.rs +++ b/crates/storage/src/vault/path.rs @@ -365,9 +365,10 @@ mod tests { let global_base = temp.path().to_path_buf(); let default_base = temp.path().join("default"); - let result = resolve_base(&global_base, &default_base); - - assert_eq!(result, default_base); + with_env(VAULT_BASE_ENV_VAR, None, || { + let result = resolve_base(&global_base, &default_base); + assert_eq!(result, default_base); + }); } } From 4a15523001c60282a0455be7be2fb0ab255f1c22 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:23:33 +0000 Subject: [PATCH 7/8] refactor: simplify handleToggleIgnoredApp, optimize set_included_bundle_ids, add priority test Co-Authored-By: John --- apps/desktop/src/settings/general/notification.tsx | 4 +--- plugins/detect/src/ext.rs | 6 +++--- plugins/detect/src/policy.rs | 10 ++++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/settings/general/notification.tsx b/apps/desktop/src/settings/general/notification.tsx index 666bbdd313..40ed475d14 100644 --- a/apps/desktop/src/settings/general/notification.tsx +++ b/apps/desktop/src/settings/general/notification.tsx @@ -217,9 +217,7 @@ export function NotificationSettingsView() { const nextIgnoredPlatforms = isCurrentlyIgnored ? ignoredPlatforms.filter((appId: string) => appId !== bundleId) - : defaultIgnored - ? ignoredPlatforms.filter((appId: string) => appId !== bundleId) - : [...ignoredPlatforms, bundleId]; + : [...ignoredPlatforms, bundleId]; const nextIncludedPlatforms = isCurrentlyIgnored ? defaultIgnored && !includedPlatforms.includes(bundleId) ? [...includedPlatforms, bundleId] diff --git a/plugins/detect/src/ext.rs b/plugins/detect/src/ext.rs index decede31f2..79f067bccc 100644 --- a/plugins/detect/src/ext.rs +++ b/plugins/detect/src/ext.rs @@ -32,9 +32,9 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { .into_iter() .collect::>(); - for id in crate::policy::default_ignored_bundle_ids() { - if !next_ids.contains(&id) { - state_guard.mic_usage_tracker.cancel_app(&id); + for id in &state_guard.policy.user_included_bundle_ids { + if !next_ids.contains(id) { + state_guard.mic_usage_tracker.cancel_app(id); } } diff --git a/plugins/detect/src/policy.rs b/plugins/detect/src/policy.rs index 260e38853d..eb1cef6379 100644 --- a/plugins/detect/src/policy.rs +++ b/plugins/detect/src/policy.rs @@ -388,6 +388,16 @@ mod tests { assert_eq!(result.filtered_apps[0].id, "com.microsoft.VSCode"); } + #[test] + fn test_user_ignored_overrides_user_included() { + let policy = MicNotificationPolicy { + user_ignored_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + user_included_bundle_ids: HashSet::from(["com.microsoft.VSCode".to_string()]), + ..Default::default() + }; + assert!(!policy.should_track_app("com.microsoft.VSCode")); + } + #[test] fn test_evaluate_mixed_apps_keeps_unknown_only() { let policy = MicNotificationPolicy::default(); From 02b58b9b2cc19bfd09ff9cbd2a91070ca447334c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:30:12 +0000 Subject: [PATCH 8/8] fix: clone prev included set to avoid borrow conflict Co-Authored-By: John --- plugins/detect/src/ext.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/detect/src/ext.rs b/plugins/detect/src/ext.rs index 79f067bccc..46e181b6b4 100644 --- a/plugins/detect/src/ext.rs +++ b/plugins/detect/src/ext.rs @@ -32,7 +32,8 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Detect<'a, R, M> { .into_iter() .collect::>(); - for id in &state_guard.policy.user_included_bundle_ids { + let prev_ids = state_guard.policy.user_included_bundle_ids.clone(); + for id in &prev_ids { if !next_ids.contains(id) { state_guard.mic_usage_tracker.cancel_app(id); }