diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 6fd4d47..2cdaf63 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -64,6 +64,12 @@ import {
resolveSelectedWorkspaceKey,
resolveSelectedWorkspaceKeyId,
} from "./lib/key-workspace-state";
+import {
+ buildInspectorEmptyStateMessage,
+ buildKeyBrowserEmptyStateMessage,
+ buildKeyBrowserStatusMessage,
+ resolveKeyBrowserSearchState,
+} from "./lib/key-browser-search";
import {
parseDemoTtlToSeconds,
persistDemoKeyTtl,
@@ -444,7 +450,9 @@ function App() {
connections.find((connection) => connection.id === activeConnectionId) ??
connections[0];
- const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearch);
+ const searchState = resolveKeyBrowserSearchState(search);
+ const deferredSearchState = resolveKeyBrowserSearchState(deferredSearch);
+ const filteredDemoKeys = filterWorkspaceKeys(demoKeys, deferredSearchState.trimmedQuery);
const filteredKeys = tauriAvailable ? liveKeys : filteredDemoKeys;
const draftErrors = validateConnectionDraft(connectionDraft, connections);
const draftErrorList = Object.values(draftErrors);
@@ -492,6 +500,22 @@ function App() {
? selectedValueRecord?.metadata.ttl.kind !== "persistent"
: selectedKey?.ttl !== "persistent");
const latestConsoleEntry = consoleHistory[0] ?? null;
+ const keyBrowserStatusMessage = buildKeyBrowserStatusMessage({
+ databaseLabel: activeDb,
+ visibleCount: filteredKeys.length,
+ hasMore: tauriAvailable && liveHasMore,
+ isLoading: isBrowseLoading,
+ search: deferredSearchState,
+ });
+ const keyBrowserEmptyStateMessage = buildKeyBrowserEmptyStateMessage({
+ databaseLabel: activeDb,
+ isLoading: isBrowseLoading,
+ search: deferredSearchState,
+ });
+ const inspectorEmptyStateMessage = buildInspectorEmptyStateMessage(
+ activeDb,
+ deferredSearchState,
+ );
const qaSnapshot = buildQaSnapshot({
appName: bootstrap.app_name,
appVersion,
@@ -564,7 +588,7 @@ function App() {
const request: RedisKeyBrowseRequest = {
connection: toRedisConnectionRequest(activeConnection, activeDb),
cursor: "0",
- pattern: toRedisSearchPattern(deferredSearch),
+ pattern: deferredSearchState.requestPattern,
page_size: browsePageSize,
};
@@ -618,7 +642,7 @@ function App() {
return () => {
active = false;
};
- }, [activeConnectionId, activeDb, deferredSearch, tauriAvailable]);
+ }, [activeConnectionId, activeDb, deferredSearchState.requestPattern, tauriAvailable]);
useEffect(() => {
if (!tauriAvailable) {
@@ -1129,7 +1153,7 @@ function App() {
request: {
connection: toRedisConnectionRequest(activeConnection, activeDb),
cursor: liveNextCursor,
- pattern: toRedisSearchPattern(deferredSearch),
+ pattern: deferredSearchState.requestPattern,
page_size: browsePageSize,
} satisfies RedisKeyBrowseRequest,
});
@@ -1732,7 +1756,25 @@ function App() {
className="w-full bg-transparent text-sm text-[var(--ink-0)] outline-none placeholder:text-[var(--ink-muted)]"
placeholder="Search key name or pattern"
/>
+ {searchState.mode === "all" ? null : (
+
+ )}
+
+
+
+ {deferredSearchState.mode === "all" ? "all keys" : deferredSearchState.mode === "pattern" ? "pattern" : "contains"}
+
+
{keyBrowserStatusMessage}
+
+
@@ -1770,9 +1812,7 @@ function App() {
{filteredKeys.length === 0 ? (
- {isBrowseLoading
- ? `Loading keys from ${activeDb}...`
- : `No keys match the current pattern in ${activeDb}.`}
+ {keyBrowserEmptyStateMessage}
) : null}
@@ -1839,7 +1879,7 @@ function App() {
)
) : (
- No key matches the current pattern in {activeDb}. Clear or adjust the search to restore an inspector target.
+ {inspectorEmptyStateMessage}
)}
diff --git a/apps/desktop/src/lib/key-browser-search.test.ts b/apps/desktop/src/lib/key-browser-search.test.ts
new file mode 100644
index 0000000..0d248ed
--- /dev/null
+++ b/apps/desktop/src/lib/key-browser-search.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildInspectorEmptyStateMessage,
+ buildKeyBrowserEmptyStateMessage,
+ buildKeyBrowserStatusMessage,
+ resolveKeyBrowserSearchState,
+} from "./key-browser-search";
+
+describe("key browser search helpers", () => {
+ it("classifies blank, contains, and wildcard search input", () => {
+ expect(resolveKeyBrowserSearchState(" ")).toEqual({
+ trimmedQuery: "",
+ requestPattern: null,
+ mode: "all",
+ });
+
+ expect(resolveKeyBrowserSearchState(" smoke ")).toEqual({
+ trimmedQuery: "smoke",
+ requestPattern: "*smoke*",
+ mode: "contains",
+ });
+
+ expect(resolveKeyBrowserSearchState("smoke:*")).toEqual({
+ trimmedQuery: "smoke:*",
+ requestPattern: "smoke:*",
+ mode: "pattern",
+ });
+ });
+
+ it("builds a stable status message for visible counts and load state", () => {
+ expect(
+ buildKeyBrowserStatusMessage({
+ databaseLabel: "db0",
+ visibleCount: 7,
+ hasMore: true,
+ isLoading: false,
+ search: resolveKeyBrowserSearchState("smoke"),
+ }),
+ ).toBe('7+ keys visible for contains search "smoke" in db0.');
+
+ expect(
+ buildKeyBrowserStatusMessage({
+ databaseLabel: "db3",
+ visibleCount: 0,
+ hasMore: false,
+ isLoading: true,
+ search: resolveKeyBrowserSearchState("smoke:*"),
+ }),
+ ).toBe('Searching db3 for "smoke:*".');
+ });
+
+ it("builds browser empty-state copy for blank and filtered results", () => {
+ expect(
+ buildKeyBrowserEmptyStateMessage({
+ databaseLabel: "db0",
+ isLoading: false,
+ search: resolveKeyBrowserSearchState(""),
+ }),
+ ).toBe("No keys are visible in db0 yet.");
+
+ expect(
+ buildKeyBrowserEmptyStateMessage({
+ databaseLabel: "db2",
+ isLoading: false,
+ search: resolveKeyBrowserSearchState("audit:*"),
+ }),
+ ).toBe('No keys match "audit:*" in db2.');
+ });
+
+ it("keeps inspector empty-state guidance aligned with browser search state", () => {
+ expect(
+ buildInspectorEmptyStateMessage("db1", resolveKeyBrowserSearchState("")),
+ ).toBe(
+ "No keys are visible in db1 yet. Browse another database or refresh after data arrives.",
+ );
+
+ expect(
+ buildInspectorEmptyStateMessage("db4", resolveKeyBrowserSearchState("jobs")),
+ ).toBe(
+ 'No keys match "jobs" in db4. Clear or adjust the search to restore an inspector target.',
+ );
+ });
+});
diff --git a/apps/desktop/src/lib/key-browser-search.ts b/apps/desktop/src/lib/key-browser-search.ts
new file mode 100644
index 0000000..661a185
--- /dev/null
+++ b/apps/desktop/src/lib/key-browser-search.ts
@@ -0,0 +1,100 @@
+export type KeyBrowserSearchMode = "all" | "contains" | "pattern";
+
+export type KeyBrowserSearchState = {
+ trimmedQuery: string;
+ requestPattern: string | null;
+ mode: KeyBrowserSearchMode;
+};
+
+type KeyBrowserFeedbackContext = {
+ databaseLabel: string;
+ visibleCount: number;
+ hasMore: boolean;
+ isLoading: boolean;
+ search: KeyBrowserSearchState;
+};
+
+export function resolveKeyBrowserSearchState(value: string): KeyBrowserSearchState {
+ const trimmedQuery = value.trim();
+ if (!trimmedQuery) {
+ return {
+ trimmedQuery: "",
+ requestPattern: null,
+ mode: "all",
+ };
+ }
+
+ if (/[*?\[\]]/.test(trimmedQuery)) {
+ return {
+ trimmedQuery,
+ requestPattern: trimmedQuery,
+ mode: "pattern",
+ };
+ }
+
+ return {
+ trimmedQuery,
+ requestPattern: `*${trimmedQuery}*`,
+ mode: "contains",
+ };
+}
+
+export function buildKeyBrowserStatusMessage({
+ databaseLabel,
+ visibleCount,
+ hasMore,
+ isLoading,
+ search,
+}: KeyBrowserFeedbackContext) {
+ if (isLoading) {
+ if (search.mode === "all") {
+ return `Loading keys from ${databaseLabel}.`;
+ }
+
+ return `Searching ${databaseLabel} for ${formatSearchLabel(search)}.`;
+ }
+
+ const visibleLabel = `${visibleCount}${hasMore ? "+" : ""}`;
+
+ if (search.mode === "all") {
+ return `${visibleLabel} keys visible in ${databaseLabel}.`;
+ }
+
+ const searchLabel =
+ search.mode === "pattern"
+ ? `pattern ${formatSearchLabel(search)}`
+ : `contains search ${formatSearchLabel(search)}`;
+
+ return `${visibleLabel} keys visible for ${searchLabel} in ${databaseLabel}.`;
+}
+
+export function buildKeyBrowserEmptyStateMessage({
+ databaseLabel,
+ isLoading,
+ search,
+}: Omit
) {
+ if (isLoading) {
+ return `Loading keys from ${databaseLabel}...`;
+ }
+
+ if (search.mode === "all") {
+ return `No keys are visible in ${databaseLabel} yet.`;
+ }
+
+ return `No keys match ${formatSearchLabel(search)} in ${databaseLabel}.`;
+}
+
+export function buildInspectorEmptyStateMessage(
+ databaseLabel: string,
+ search: KeyBrowserSearchState,
+) {
+ if (search.mode === "all") {
+ return `No keys are visible in ${databaseLabel} yet. Browse another database or refresh after data arrives.`;
+ }
+
+ return `No keys match ${formatSearchLabel(search)} in ${databaseLabel}. Clear or adjust the search to restore an inspector target.`;
+}
+
+function formatSearchLabel(search: KeyBrowserSearchState) {
+ return `"${search.trimmedQuery}"`;
+}
diff --git a/plans/2026-03-31-hac-29-browse-search-baseline.md b/plans/2026-03-31-hac-29-browse-search-baseline.md
new file mode 100644
index 0000000..33f102a
--- /dev/null
+++ b/plans/2026-03-31-hac-29-browse-search-baseline.md
@@ -0,0 +1,47 @@
+# HAC-29 Browse And Search Baseline Stabilization
+
+Date: 2026-03-31 UTC
+Owner: Senior Frontend Engineer
+Issue: HAC-29
+Scope: `desktop-v1` browse/search hardening only
+
+## Context
+
+- `apps/desktop` already supports live browse pagination and DB-scoped search, but the visible search surface still leaves a few operator-facing gaps:
+ - no explicit feedback about whether input is treated as substring matching or raw Redis pattern matching
+ - no bounded clear action from the browser header
+ - one generic empty-state sentence was doing too much work across loading, empty DB, and filtered no-match cases
+- Current phase must stay inside browse/search hardening and must not expand into Add Key, delete execution, non-string editing, or visual refresh work.
+
+## Decision
+
+- Keep the existing workspace layout and backend contracts unchanged.
+- Extract search-state interpretation into a shared frontend helper so visible UI copy and backend browse requests use the same source of truth.
+- Improve only the key-browser and inspector feedback layer:
+ - explicit search mode label (`all keys`, `contains`, `pattern`)
+ - concise result-status message under the search input
+ - clear action for returning to normal browse state
+ - distinct empty-state copy for loading, empty DB, and no-match search results
+
+## Implemented Slice
+
+1. Added `apps/desktop/src/lib/key-browser-search.ts` plus focused tests for:
+ - blank input vs contains search vs raw wildcard-pattern classification
+ - backend request-pattern generation
+ - browser status and empty-state copy
+ - inspector empty-state copy aligned to the same search state
+2. Updated `apps/desktop/src/App.tsx` to:
+ - consume the shared search helper for live browse requests
+ - render a visible search-mode badge and status line under the browser search field
+ - expose a bounded clear action when search input is active
+ - keep browser and inspector empty-state feedback aligned
+3. Updated `docs/frontend-workspace-baseline.md` so the shared acceptance language now includes the search-mode feedback, clear action, and differentiated empty states.
+
+## Verification
+
+- `pnpm --filter @redis-gui/desktop test` -> pass (9 files / 33 tests)
+- `pnpm run desktop:build` -> pass
+
+## Remaining Risk
+
+- This environment still cannot launch a visible Tauri window, so final GUI-level browse/search smoke evidence remains a QA follow-up even though the frontend code and build path are green.