From 8360746ba0701cacdfcd9edb10c80d54a232bfc9 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 14:56:28 -0400 Subject: [PATCH 1/4] fix(channels): close stray thread when the channel feed mounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread panel's open state (threadPanelStore.openByChannel) is route-independent, and nothing cleared it when the feed remounted — so feed → task → back to feed could repaint a task sidebar beside the feed. Feed and thread panel are mutually exclusive, so clear the channel's open-thread entry on feed mount. Generated-By: PostHog Code Task-Id: cdb544dd-fce7-4877-9c38-e91299400585 --- .../components/WebsiteChannelHome.test.tsx | 102 ++++++++++++++++++ .../canvas/components/WebsiteChannelHome.tsx | 9 +- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx new file mode 100644 index 0000000000..b69be59ee3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx @@ -0,0 +1,102 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +vi.mock("@posthog/ui/shell/rendererStorage", () => ({ + electronStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ + channels: [{ id: "chan-1", name: "eng" }], + isLoading: false, + }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => true, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", () => ({ + PERSONAL_CHANNEL_NAME: "me", + useBackendChannel: () => ({ + channel: { id: "backend-1", name: "eng" }, + isLoading: false, + }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelFeed", () => ({ + useChannelFeed: () => ({ tasks: [], isLoading: false }), + channelFeedQueryKey: () => ["feed"], +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelFeedMessages", () => ({ + useChannelFeedMessages: () => ({ messages: [], isLoading: false }), + channelCreationMessage: () => null, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useFolderInstructions", () => ({ + useFolderInstructions: () => ({ data: undefined, isLoading: false }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelTasks", () => ({ + useChannelTaskMutations: () => ({ fileTask: () => Promise.resolve() }), +})); +vi.mock("@posthog/ui/hooks/useSetHeaderContent", () => ({ + useSetHeaderContent: () => {}, +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ setQueryData: vi.fn(), invalidateQueries: vi.fn() }), +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); + +// ThreadSidebar is the task dock under test; the rest of the channel chrome +// (feed rows, composer, intro) plays no part in the feed/sidebar exclusion. +vi.mock("@posthog/ui/features/canvas/components/ChannelFeedView", () => ({ + ChannelFeedView: () =>
, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelHomeComposer", () => ({ + ChannelHomeComposer: () => null, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelIntro", () => ({ + ChannelIntro: () => null, +})); +vi.mock("@posthog/ui/features/canvas/components/CreateChannelModal", () => ({ + CreateChannelModal: () => null, +})); +vi.mock("@posthog/ui/features/canvas/components/ThreadSidebar", () => ({ + ThreadSidebar: () =>
, +})); + +import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; +import { WebsiteChannelHome } from "./WebsiteChannelHome"; + +describe("WebsiteChannelHome", () => { + beforeEach(() => { + useThreadPanelStore.setState({ + openByChannel: {}, + collapsed: false, + width: 360, + }); + }); + + it("drops a stale open thread so the feed can't show a task sidebar", () => { + useThreadPanelStore.getState().openThread("chan-1", "task-1"); + render( + + + , + ); + + expect(screen.getByTestId("feed")).toBeTruthy(); + // The feed and the task sidebar are mutually exclusive. + expect(screen.queryByTestId("task-sidebar")).toBeNull(); + expect(useThreadPanelStore.getState().openByChannel["chan-1"]).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 3195ba4877..ec3cdbc51f 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -44,7 +44,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { Heading, Text } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; // A channel: a Slack-style multiplayer feed. Each member message kicks off a // task rendered as a card everyone in the channel sees; the composer stays @@ -135,6 +135,13 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); + // The open thread outlives the thread view, so the feed showing itself is + // the only signal that it's gone — clear it here. + useEffect(() => { + const open = useThreadPanelStore.getState().openByChannel[channelId]; + if (open) useThreadPanelStore.getState().closeThread(channelId); + }, [channelId]); + const handleSuggestionSelect = useCallback( (prompt: string, mode?: string) => { composerRef.current?.applySuggestion(prompt, mode); From df67e26009ad9daa32d738449066cdf860df6234 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 14:57:09 -0400 Subject: [PATCH 2/4] chore: drop restated comment from channel feed regression test Generated-By: PostHog Code Task-Id: cdb544dd-fce7-4877-9c38-e91299400585 --- .../src/features/canvas/components/WebsiteChannelHome.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx index b69be59ee3..f7aa510d1f 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx @@ -95,7 +95,6 @@ describe("WebsiteChannelHome", () => { ); expect(screen.getByTestId("feed")).toBeTruthy(); - // The feed and the task sidebar are mutually exclusive. expect(screen.queryByTestId("task-sidebar")).toBeNull(); expect(useThreadPanelStore.getState().openByChannel["chan-1"]).toBeNull(); }); From 2f05d00e7c48a04df439b255524070296e9ff112 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Thu, 30 Jul 2026 15:07:21 -0400 Subject: [PATCH 3/4] fix(channels): suppress stale threads during render, not after paint Greptile P1: clearing the open-thread entry in a passive effect still committed ThreadSidebar for one frame, flashing the overlapping view. Snapshot the inherited entry during the first render, exclude it from the sidebar render, and clear the store in the effect for hygiene. Threads opened from this feed instance are unaffected. Generated-By: PostHog Code Task-Id: cdb544dd-fce7-4877-9c38-e91299400585 --- .../components/WebsiteChannelHome.test.tsx | 17 ++++++++++++++++- .../canvas/components/WebsiteChannelHome.tsx | 18 ++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx index f7aa510d1f..a8ccd8d356 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.test.tsx @@ -1,5 +1,5 @@ import { Theme } from "@radix-ui/themes"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; if (typeof globalThis.ResizeObserver === "undefined") { @@ -98,4 +98,19 @@ describe("WebsiteChannelHome", () => { expect(screen.queryByTestId("task-sidebar")).toBeNull(); expect(useThreadPanelStore.getState().openByChannel["chan-1"]).toBeNull(); }); + + it("shows the task sidebar for a thread opened from this feed", () => { + render( + + + , + ); + + act(() => { + useThreadPanelStore.getState().openThread("chan-1", "task-1"); + }); + + expect(screen.getByTestId("task-sidebar")).toBeTruthy(); + expect(screen.queryByTestId("feed")).toBeTruthy(); + }); }); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index ec3cdbc51f..bb90b8b07f 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -136,10 +136,20 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const closeThread = useThreadPanelStore((s) => s.closeThread); // The open thread outlives the thread view, so the feed showing itself is - // the only signal that it's gone — clear it here. + // the only signal that it's gone. Suppress an entry this mount inherited — + // one the user didn't open from this feed instance — and clear it from the + // store; threads opened from here paint normally. Clearing in an effect + // alone would paint the sidebar for a frame first. + const bornStaleRef = useRef(null); + if (bornStaleRef.current === null) { + bornStaleRef.current = + useThreadPanelStore.getState().openByChannel[channelId] ?? ""; + } useEffect(() => { - const open = useThreadPanelStore.getState().openByChannel[channelId]; - if (open) useThreadPanelStore.getState().closeThread(channelId); + if (bornStaleRef.current) { + useThreadPanelStore.getState().closeThread(channelId); + bornStaleRef.current = ""; + } }, [channelId]); const handleSuggestionSelect = useCallback( @@ -324,7 +334,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
- {threadTaskId && ( + {threadTaskId && threadTaskId !== bornStaleRef.current && ( Date: Thu, 30 Jul 2026 15:09:56 -0400 Subject: [PATCH 4/4] refactor(channels): simplify stale-thread suppression to a lazy useState The ref-based version split the same fact across render, a mutable ref, and an effect. A lazy useState initialiser captures the inherited entry once; the render excludes it and one effect clears the store. Same behavior, one less moving part. Generated-By: PostHog Code Task-Id: cdb544dd-fce7-4877-9c38-e91299400585 --- .../canvas/components/WebsiteChannelHome.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index bb90b8b07f..e34e11a7d2 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -136,21 +136,17 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const closeThread = useThreadPanelStore((s) => s.closeThread); // The open thread outlives the thread view, so the feed showing itself is - // the only signal that it's gone. Suppress an entry this mount inherited — - // one the user didn't open from this feed instance — and clear it from the - // store; threads opened from here paint normally. Clearing in an effect - // alone would paint the sidebar for a frame first. - const bornStaleRef = useRef(null); - if (bornStaleRef.current === null) { - bornStaleRef.current = - useThreadPanelStore.getState().openByChannel[channelId] ?? ""; - } + // the only signal an inherited thread is gone. Suppress it in render (an + // effect alone would paint the sidebar for a frame first) and clear the + // store; threads opened from this feed instance paint normally. + const [inheritedThreadTaskId] = useState( + () => useThreadPanelStore.getState().openByChannel[channelId] ?? null, + ); useEffect(() => { - if (bornStaleRef.current) { + if (inheritedThreadTaskId) { useThreadPanelStore.getState().closeThread(channelId); - bornStaleRef.current = ""; } - }, [channelId]); + }, [channelId, inheritedThreadTaskId]); const handleSuggestionSelect = useCallback( (prompt: string, mode?: string) => { @@ -334,7 +330,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { - {threadTaskId && threadTaskId !== bornStaleRef.current && ( + {threadTaskId && threadTaskId !== inheritedThreadTaskId && (