Skip to content

Desktop search opens replies without parent thread or channel context #6146

Open

Description

@cmyk

Summary

Opening a message result from Desktop search can strand the user in an isolated nested-branch view instead of showing where the message belongs.

The panel displays only the matched reply and ends with:

No replies in this branch yet
Reply in thread to continue this branch

There is no visible originating channel message, no highlighted match in its parent conversation, no parent/root breadcrumb, and no action to open the complete parent thread. The composer consequently encourages the user to create another nested branch without making that consequence clear.

Steps to reproduce

  1. In Buzz Desktop, search for text contained in a reply inside an existing channel thread.
  2. Open the matching message result.
  3. Observe the thread panel.

Actual behavior

  • The matched reply is opened as an isolated branch root.
  • The originating channel/root message is not visible or highlighted.
  • Earlier thread context is absent.
  • No channel → root thread → matched reply breadcrumb is shown.
  • There is no Open parent thread or Back to channel message action.
  • The empty-branch composer invites a reply that would create another nested branch.

This makes a successfully found message practically unusable for recovering and continuing a conversation.

Expected behavior

Opening a message search result should preserve conversation context:

  • open the complete parent/root thread;
  • scroll to and visibly highlight the matched message;
  • identify the originating channel and root message;
  • provide explicit Open parent thread and/or Back to channel navigation;
  • avoid presenting an empty nested-branch composer as though it were the recovered conversation.

If an isolated-branch view remains supported, it should clearly expose its parent chain and offer a one-click route to the root conversation.

Acceptance criteria

  • Opening a reply from search exposes the originating channel and root thread.
  • The matched message is scrolled into view and visibly highlighted.
  • The user can navigate directly to the parent/root conversation.
  • The UI clearly distinguishes replying to the matched message from continuing the root thread.
  • Search results for top-level messages, root messages, direct replies, and nested replies have automated navigation coverage.

Related issues checked

Activity

  1. cmyk commented on Aug 18, 2026

    @cmyk
    Author

    Clicking a macOS notification for a direct or nested reply must open the complete parent/root thread, highlight the notified reply, and provide channel/parent navigation even when the notification payload lacks an explicit threadRootId.

  2. Giszmo commented on Sep 15, 2026

    @Giszmo

    Reproduced on main (051c3a2) and traced. This is the same defect as the notification case, and the open PR #7452 fixes both — but it is written and tested only as a notification fix, so the link to this issue is easy to miss.

    Mechanism. Search results and notifications share one navigation path: openSearchHitWithNavigation → cacheSearchHitEvent + goChannel → ChannelRouteScreen. buildSearchHitEvent (desktop/src/app/navigation/searchHitEventCache.ts) projects a hit into a RelayEvent whose only tag is ["h", channelId] — no e reply tags, because the search response carries no thread root at any layer (SearchHit in crates/buzz-search/src/query.rs, SearchHitInfo in desktop/src-tauri/src/models.rs, fromRawSearchHit in desktop/src/shared/api/tauri.ts). ChannelRouteScreen splices that projection into the timeline at mount, so useChannelRouteTarget sees targetMessage.parentId === undefined, takes the root-message branch, opens the matched reply as its own thread head, and records it in handledThreadRouteTargetRef — after which hydrating the real event can no longer repair the selection. That is the "No replies in this branch yet" panel described above.

    Evidence. I added a search-path regression to desktop/tests/e2e/navigation.spec.ts — emit root → sibling → parent → nested reply into a mock channel, hold the target's get_event with __BUZZ_E2E_DEFER_GET_EVENT__, search for the nested reply, click the result, release hydration, then require the root and both ancestors in the thread panel:

    Test case (mirrors the one in #7452)
    test("search result hydration opens the full thread instead of the cached reply", async ({
      page,
    }) => {
      await page.goto("/");
      await page.getByTestId("channel-general").click();
      await expect(page.getByTestId("message-input")).toBeVisible();
      const targetId = await page.evaluate(() => {
        const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
        if (!emit) throw new Error("Mock message fixture unavailable");
        const input = {
          channelName: "engineering",
          pubkey:
            "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
        };
        const root = emit({ ...input, content: "Search context root" });
        emit({
          ...input,
          parentEventId: root.id,
          content: "Sibling reply retains context",
        });
        const parent = emit({
          ...input,
          parentEventId: root.id,
          content: "Parent reply retains context",
        });
        const target = emit({
          ...input,
          parentEventId: parent.id,
          content: "Nested searchable reply",
        });
        window.__BUZZ_E2E_DEFER_GET_EVENT__ = target.id;
        return target.id;
      });
    
      await page.keyboard.press("ControlOrMeta+k");
      await page.getByTestId("search-dialog-input").fill("Nested searchable reply");
      const hit = page.getByTestId(`search-result-${targetId}`);
      await expect(hit).toBeVisible();
      await hit.click();
      await expect(page.getByTestId("chat-title")).toHaveText("engineering");
      // Hold the authoritative lookup until navigation has committed. The cached
      // search hit has no reply tags and must not become the selected thread.
      await page.evaluate(() => window.__BUZZ_E2E_RELEASE_GET_EVENT__?.());
      const thread = page.getByTestId("message-thread-panel");
      await expect(thread.getByTestId("message-thread-head")).toContainText(
        "Search context root",
      );
      await expect(thread).toContainText("Sibling reply retains context");
      await expect(thread).toContainText("Parent reply retains context");
      await expect(thread.locator(`[data-message-id="${targetId}"]`)).toContainText(
        "Nested searchable reply",
      );
    });

    Two parts of this report that #7452 does not cover, in case they should be split out:

    1. The breadcrumb and explicit Open parent thread / Back to channel affordances. fix(desktop): preserve full thread context when opening reply notifications #7452 restores the thread, not the navigation affordances.
    2. When the lookup fails (offline, or the event is not retrievable) fetchRouteEvent returns null and the tag-less projection is inserted on its own, so the isolated-branch behaviour returns. The belt-and-braces fix is to carry the thread root in the search hit itself, which would also let resolveSearchHitDestination stop passing threadRootId: null for every message hit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions