Show New and Updated badges on recently added or revamped tools

This commit is contained in:
Anthony Stirling
2026-08-28 18:34:30 +01:00
parent d3708c1e63
commit e34abda709
14 changed files with 509 additions and 27 deletions
+6 -3
View File
@@ -128,7 +128,7 @@ export default [ToolName] as ToolComponent;
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/editor/src/data/useTranslatedToolRegistry.tsx`):
**Tool Registry** (`frontend/editor/src/core/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
@@ -147,11 +147,14 @@ import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Setting
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
maxFiles: -1, // or specific number
endpoints: ["endpoint-name"],
operationConfig: [toolName]OperationConfig,
settingsComponent: [ToolName]Settings, // if settings exist
operationConfig: asRegistryConfig([toolName]OperationConfig),
automationSettings: [ToolName]Settings, // or null if no automation settings
newInVersion: "2.16.0", // release the tool first ships in
},
```
**"New"/"Updated" badges**: set `newInVersion` to the release the tool first ships in so the tool list shows a "New" badge; use `updatedInVersion` when an existing tool gets a major revamp to show "Updated". Badges expire automatically once that release is more than one minor behind the running app, and per user once they open the tool (see `frontend/editor/src/core/utils/toolFreshness.ts`).
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
@@ -11275,13 +11275,16 @@ ZIP = "a ZIP archive"
[toolPanel]
alpha = "Alpha"
backToAllTools = "Back to all tools"
beta = "Beta"
collapse = "Collapse panel"
expand = "Expand panel"
goBack = "Go back"
new = "New"
pdfTools = "PDF Tools"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
toolsHeader = "Tools"
updated = "Updated"
viewAllTools = "View all tools"
[toolPanel.fullscreen]
@@ -1,9 +1,10 @@
import React from "react";
import { Text, Badge } from "@mantine/core";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@app/components/shared/Tooltip";
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges";
import {
ToolRegistryEntry,
getSubcategoryColor,
@@ -82,11 +83,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({
<span className="tool-panel__fullscreen-list-body">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
{tool.versionStatus === "alpha" && (
<Badge size="xs" variant="light" color="orange">
{t("toolPanel.alpha", "Alpha")}
</Badge>
)}
<ToolStatusBadges toolId={id} tool={tool} />
</div>
</span>
{!disabled && (
@@ -1,8 +1,9 @@
import React from "react";
import { Text, Badge } from "@mantine/core";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges";
import {
ToolRegistryEntry,
getSubcategoryColor,
@@ -80,12 +81,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({
<span className="tool-panel__fullscreen-body">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Text className="tool-panel__fullscreen-name">{tool.name}</Text>
{tool.versionStatus === "alpha" && (
<Badge size="xs" variant="light" color="orange">
{/* we can add more translations for different badges in future, like beta, etc. */}
{t("toolPanel.alpha", "Alpha")}
</Badge>
)}
<ToolStatusBadges toolId={id} tool={tool} />
</div>
<Text
size="sm"
@@ -0,0 +1,68 @@
import { Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { useToolFreshnessBadge } from "@app/hooks/tools/useToolFreshnessBadge";
interface ToolStatusBadgesProps {
toolId: string;
tool: ToolRegistryEntry;
// Matches the 0.25 opacity tool rows use when visually unavailable.
dimmed?: boolean;
}
/** Status chips shown next to a tool's name: Alpha/Beta plus New/Updated. */
const ToolStatusBadges = ({
toolId,
tool,
dimmed = false,
}: ToolStatusBadgesProps) => {
const { t } = useTranslation();
const freshness = useToolFreshnessBadge(toolId, tool);
const badges: { key: string; color: string; label: string }[] = [];
if (tool.versionStatus === "alpha") {
badges.push({
key: "alpha",
color: "orange",
label: t("toolPanel.alpha", "Alpha"),
});
} else if (tool.versionStatus === "beta") {
badges.push({
key: "beta",
color: "orange",
label: t("toolPanel.beta", "Beta"),
});
}
if (freshness === "new") {
badges.push({
key: "new",
color: "teal",
label: t("toolPanel.new", "New"),
});
} else if (freshness === "updated") {
badges.push({
key: "updated",
color: "blue",
label: t("toolPanel.updated", "Updated"),
});
}
if (badges.length === 0) return null;
return (
<>
{badges.map(({ key, color, label }) => (
<Badge
key={key}
size="xs"
variant="light"
color={color}
style={{ flexShrink: 0, opacity: dimmed ? 0.25 : 1 }}
>
{label}
</Badge>
))}
</>
);
};
export default ToolStatusBadges;
@@ -11,6 +11,7 @@ import FitText from "@app/components/shared/FitText";
import { useHotkeys } from "@app/contexts/HotkeyContext";
import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges";
import {
useToolWorkflowActions,
useToolWorkflowData,
@@ -176,16 +177,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({
opacity: visuallyUnavailable ? 0.25 : 1,
}}
/>
{tool.versionStatus === "alpha" && (
<Badge
size="xs"
variant="light"
color="orange"
style={{ flexShrink: 0, opacity: visuallyUnavailable ? 0.25 : 1 }}
>
{t("toolPanel.alpha", "Alpha")}
</Badge>
)}
<ToolStatusBadges
toolId={id}
tool={tool}
dimmed={visuallyUnavailable}
/>
{typeof badgeCount === "number" && badgeCount > 0 && (
<Badge
size="sm"
@@ -32,6 +32,7 @@ import { useNavigationUrlSync } from "@app/hooks/useUrlSync";
import { stripBasePath } from "@app/constants/app";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
import { acknowledgeToolFreshness } from "@app/utils/toolFreshness";
import { useToolHistory } from "@app/hooks/tools/useUserToolActivity";
import {
ToolWorkflowState,
@@ -434,6 +435,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setLeftPanelView,
]);
// Opening a tool counts as seeing it: clear its New/Updated badge.
useEffect(() => {
const toolId = navigationState.selectedTool;
if (!toolId) return;
const tool = allTools[toolId];
if (tool) acknowledgeToolFreshness(toolId, tool);
}, [navigationState.selectedTool, allTools]);
// Tool reset methods
const registerToolReset = useCallback(
(toolId: string, resetFunction: () => void) => {
@@ -78,6 +78,10 @@ export type ToolRegistryEntry = {
synonyms?: string[];
// Version status indicator (e.g., "alpha", "beta")
versionStatus?: "alpha" | "beta";
// Release that introduced the tool; shows a "New" badge while that release is recent.
newInVersion?: string;
// Release of the tool's last major revamp; shows an "Updated" badge while recent.
updatedInVersion?: string;
// Whether this tool requires premium access
requiresPremium?: boolean;
};
@@ -239,6 +239,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: null,
supportsAutomate: false,
synonyms: getSynonyms(t, "sharedSign"),
newInVersion: "2.14.0",
},
addText: {
icon: (
@@ -480,6 +481,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
"modify",
"builder",
],
// Fill Form became a full Form Editor (create/edit/delete fields).
updatedInVersion: "2.15.0",
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@@ -677,6 +680,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
import("@app/components/tools/autoRotate/AutoRotateAutomationSettings"),
),
synonyms: getSynonyms(t, "autoRotate"),
newInVersion: "2.15.0",
},
split: {
icon: (
@@ -0,0 +1,30 @@
import { useSyncExternalStore } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
getAcknowledgedToolVersions,
getToolFreshness,
isToolFreshnessAcknowledged,
subscribeToolFreshness,
type ToolFreshnessBadge,
} from "@app/utils/toolFreshness";
// "New"/"Updated" badge for a tool; null once the tagged release is no longer
// recent or the user already opened the tool at that version.
export function useToolFreshnessBadge(
toolId: string,
tool: Pick<ToolRegistryEntry, "newInVersion" | "updatedInVersion">,
): ToolFreshnessBadge | null {
const { config } = useAppConfig();
const acknowledged = useSyncExternalStore(
subscribeToolFreshness,
getAcknowledgedToolVersions,
getAcknowledgedToolVersions,
);
const freshness = getToolFreshness(tool, config?.appVersion);
if (!freshness) return null;
if (isToolFreshnessAcknowledged(acknowledged, toolId, freshness.version)) {
return null;
}
return freshness.badge;
}
@@ -0,0 +1,73 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page, Route } from "@playwright/test";
/**
* "New"/"Updated" tool badges, driven by newInVersion/updatedInVersion tags in
* the registry compared against app-config's appVersion (see toolFreshness.ts).
* The app version is pinned per test so these stay hermetic as releases move on:
* autoRotate keeps newInVersion 2.15.0 forever, so at a pinned 2.15.x it is
* always "recent" here regardless of the real current version.
*/
test.use({ autoGoto: false });
async function openEditorAtVersion(page: Page, appVersion: string) {
// Last-registered route wins, so this overrides the fixture's app-config stub.
await page.route("**/api/v1/config/app-config", (route: Route) =>
route.fulfill({
json: {
enableLogin: false,
isAdmin: false,
languages: ["en-US"],
defaultLocale: "en-US",
appVersion,
},
}),
);
await page.goto("/editor", { waitUntil: "domcontentloaded" });
}
const toolButton = (page: Page, toolId: string) =>
page.locator(`[data-tour="tool-button-${toolId}"]`).first();
const badge = (page: Page, toolId: string, label: string) =>
toolButton(page, toolId).getByText(label, { exact: true });
test.describe("Tool freshness badges", () => {
test("recent tools show New/Updated and opening a tool clears its badge", async ({
page,
}) => {
await openEditorAtVersion(page, "2.15.1");
// autoRotate: newInVersion 2.15.0. formFill: updatedInVersion 2.15.0.
await expect(badge(page, "autoRotate", "New")).toBeVisible();
await expect(badge(page, "formFill", "Updated")).toBeVisible();
// An untagged tool gets neither badge.
await expect(toolButton(page, "merge")).toBeVisible();
await expect(badge(page, "merge", "New")).toHaveCount(0);
await expect(badge(page, "merge", "Updated")).toHaveCount(0);
// Opening the tool acknowledges it; back on the list the badge is gone.
await toolButton(page, "autoRotate").click();
await page.goto("/editor", { waitUntil: "domcontentloaded" });
await expect(toolButton(page, "autoRotate")).toBeVisible();
await expect(badge(page, "autoRotate", "New")).toHaveCount(0);
await expect(badge(page, "formFill", "Updated")).toBeVisible();
// The acknowledgement is persisted, so a reload doesn't resurrect it.
await page.reload({ waitUntil: "domcontentloaded" });
await expect(toolButton(page, "autoRotate")).toBeVisible();
await expect(badge(page, "autoRotate", "New")).toHaveCount(0);
});
test("badges expire once the tagged release is over a minor behind", async ({
page,
}) => {
await openEditorAtVersion(page, "2.17.0");
await expect(toolButton(page, "autoRotate")).toBeVisible();
await expect(badge(page, "autoRotate", "New")).toHaveCount(0);
await expect(toolButton(page, "formFill")).toBeVisible();
await expect(badge(page, "formFill", "Updated")).toHaveCount(0);
});
});
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, test } from "vitest";
import {
acknowledgeToolFreshness,
getAcknowledgedToolVersions,
getToolFreshness,
isRecentRelease,
isToolFreshnessAcknowledged,
latestTaggedVersion,
resetToolFreshnessCache,
} from "@app/utils/toolFreshness";
const STORAGE_KEY = "stirling.toolFreshness.acknowledged";
beforeEach(() => {
window.localStorage.clear();
resetToolFreshnessCache();
});
describe("isRecentRelease", () => {
test("current and previous minor are recent", () => {
expect(isRecentRelease("2.15.0", "2.15.2")).toBe(true);
expect(isRecentRelease("2.14.0", "2.15.2")).toBe(true);
});
test("two minors back is stale", () => {
expect(isRecentRelease("2.13.0", "2.15.2")).toBe(false);
});
test("tagged version ahead of the build is recent", () => {
expect(isRecentRelease("2.16.0", "2.15.2")).toBe(true);
expect(isRecentRelease("3.0.0", "2.15.2")).toBe(true);
expect(isRecentRelease("2.15.0", "0.0.0")).toBe(true);
});
test("older major is stale", () => {
expect(isRecentRelease("1.9.0", "2.0.0")).toBe(false);
});
test("unknown app version keeps badges visible", () => {
expect(isRecentRelease("2.15.0", undefined)).toBe(true);
expect(isRecentRelease("2.15.0", null)).toBe(true);
});
test("unparseable tagged version never badges", () => {
expect(isRecentRelease("next", "2.15.0")).toBe(false);
expect(isRecentRelease("", "2.15.0")).toBe(false);
});
test("accepts a v prefix", () => {
expect(isRecentRelease("v2.15.0", "2.15.2")).toBe(true);
});
});
describe("latestTaggedVersion", () => {
test("picks the higher of new and updated", () => {
expect(
latestTaggedVersion({
newInVersion: "2.14.0",
updatedInVersion: "2.15.0",
}),
).toBe("2.15.0");
expect(
latestTaggedVersion({
newInVersion: "2.15.0",
updatedInVersion: "2.14.0",
}),
).toBe("2.15.0");
expect(latestTaggedVersion({ newInVersion: "2.14.0" })).toBe("2.14.0");
expect(latestTaggedVersion({})).toBeNull();
});
});
describe("getToolFreshness", () => {
test("recent newInVersion shows New", () => {
expect(getToolFreshness({ newInVersion: "2.15.0" }, "2.15.1")).toEqual({
badge: "new",
version: "2.15.0",
});
});
test("stale newInVersion with recent update shows Updated", () => {
expect(
getToolFreshness(
{ newInVersion: "2.10.0", updatedInVersion: "2.15.0" },
"2.15.1",
),
).toEqual({ badge: "updated", version: "2.15.0" });
});
test("New outranks Updated and advertises the latest version", () => {
expect(
getToolFreshness(
{ newInVersion: "2.14.0", updatedInVersion: "2.15.0" },
"2.15.1",
),
).toEqual({ badge: "new", version: "2.15.0" });
});
test("stale or missing versions produce no badge", () => {
expect(getToolFreshness({ newInVersion: "2.10.0" }, "2.15.1")).toBeNull();
expect(getToolFreshness({}, "2.15.1")).toBeNull();
});
});
describe("acknowledgements", () => {
test("acknowledging hides that version but not a later one", () => {
acknowledgeToolFreshness("autoRotate", { newInVersion: "2.15.0" });
let acknowledged = getAcknowledgedToolVersions();
expect(
isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.15.0"),
).toBe(true);
expect(
isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.16.0"),
).toBe(false);
// A later update re-surfaces the badge until acknowledged again.
acknowledgeToolFreshness("autoRotate", {
newInVersion: "2.15.0",
updatedInVersion: "2.16.0",
});
acknowledged = getAcknowledgedToolVersions();
expect(
isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.16.0"),
).toBe(true);
});
test("persists to localStorage and survives a cache reset", () => {
acknowledgeToolFreshness("sharedSign", { newInVersion: "2.14.0" });
resetToolFreshnessCache();
expect(
isToolFreshnessAcknowledged(
getAcknowledgedToolVersions(),
"sharedSign",
"2.14.0",
),
).toBe(true);
});
test("untagged tools are never recorded", () => {
acknowledgeToolFreshness("merge", {});
expect(getAcknowledgedToolVersions()).toEqual({});
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
});
test("corrupt stored state is ignored", () => {
window.localStorage.setItem(STORAGE_KEY, "not json");
expect(getAcknowledgedToolVersions()).toEqual({});
resetToolFreshnessCache();
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(["a"]));
expect(getAcknowledgedToolVersions()).toEqual({});
resetToolFreshnessCache();
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ good: "2.15.0", bad: 42 }),
);
expect(getAcknowledgedToolVersions()).toEqual({ good: "2.15.0" });
});
});
@@ -0,0 +1,137 @@
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { updateService } from "@app/services/updateService";
export type ToolFreshnessBadge = "new" | "updated";
export interface ToolFreshnessInfo {
badge: ToolFreshnessBadge;
// Version the badge advertises; acknowledging it (or newer) hides the badge.
version: string;
}
type FreshnessFields = Pick<
ToolRegistryEntry,
"newInVersion" | "updatedInVersion"
>;
// Badges expire on their own once the tagged release is this many minors behind.
const RECENT_MINOR_WINDOW = 1;
const STORAGE_KEY = "stirling.toolFreshness.acknowledged";
function parseMajorMinor(
version: string,
): { major: number; minor: number } | null {
const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
if (!match) return null;
return { major: Number(match[1]), minor: Number(match[2]) };
}
// Recent = within RECENT_MINOR_WINDOW of the app's minor, or newer than the
// build itself (registry tagged ahead, e.g. dev builds reporting 0.0.0).
export function isRecentRelease(
taggedVersion: string,
appVersion: string | null | undefined,
): boolean {
const tagged = parseMajorMinor(taggedVersion);
if (!tagged) return false;
const current = appVersion ? parseMajorMinor(appVersion) : null;
if (!current) return true;
if (tagged.major !== current.major) return tagged.major > current.major;
return current.minor - tagged.minor <= RECENT_MINOR_WINDOW;
}
// Highest version the tool's registry entry is tagged with, if any.
export function latestTaggedVersion(tool: FreshnessFields): string | null {
const { newInVersion, updatedInVersion } = tool;
if (newInVersion && updatedInVersion) {
return updateService.compareVersions(updatedInVersion, newInVersion) >= 0
? updatedInVersion
: newInVersion;
}
return updatedInVersion ?? newInVersion ?? null;
}
// "New" outranks "Updated": a tool still inside its launch window is just new.
export function getToolFreshness(
tool: FreshnessFields,
appVersion: string | null | undefined,
): ToolFreshnessInfo | null {
const { newInVersion, updatedInVersion } = tool;
if (newInVersion && isRecentRelease(newInVersion, appVersion)) {
return { badge: "new", version: latestTaggedVersion(tool) ?? newInVersion };
}
if (updatedInVersion && isRecentRelease(updatedInVersion, appVersion)) {
return { badge: "updated", version: updatedInVersion };
}
return null;
}
type AcknowledgedVersions = Readonly<Record<string, string>>;
let acknowledgedCache: AcknowledgedVersions | null = null;
const listeners = new Set<() => void>();
function readAcknowledged(): AcknowledgedVersions {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
} catch {
return {};
}
}
// Stable snapshot for useSyncExternalStore; replaced wholesale on writes.
export function getAcknowledgedToolVersions(): AcknowledgedVersions {
if (!acknowledgedCache) acknowledgedCache = readAcknowledged();
return acknowledgedCache;
}
export function subscribeToolFreshness(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function isToolFreshnessAcknowledged(
acknowledged: AcknowledgedVersions,
toolId: string,
version: string,
): boolean {
const seen = acknowledged[toolId];
return !!seen && updateService.compareVersions(seen, version) >= 0;
}
// Records that the user has opened the tool at its currently tagged version.
export function acknowledgeToolFreshness(
toolId: string,
tool: FreshnessFields,
): void {
const version = latestTaggedVersion(tool);
if (!version) return;
const current = getAcknowledgedToolVersions();
if (isToolFreshnessAcknowledged(current, toolId, version)) return;
acknowledgedCache = { ...current, [toolId]: version };
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(acknowledgedCache));
} catch {
// Storage being unavailable only means the badge reappears next visit.
}
listeners.forEach((listener) => listener());
}
// Test hook: drops the in-memory cache so the next read hits localStorage.
export function resetToolFreshnessCache(): void {
acknowledgedCache = null;
}
+3 -1
View File
@@ -92,7 +92,9 @@ function taskCommand() {
for (const candidate of candidates) {
if (existsSync(candidate)) return { command: candidate, shell: false };
}
return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" };
// Plain "task" lets cmd resolve the extension via PATHEXT, so .exe shims
// (scoop, winget) work too; hard-coding .cmd made those exit 1 with no output.
return { command: "task", shell: process.platform === "win32" };
}
function run() {