From 37970f0c450c412e6c1e8ad81c5ab33d713f4775 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:14:34 +0000 Subject: [PATCH 1/3] fix(posthog-ai): keep a closed context chip closed only on its page Closing a context chip in the PostHog AI composer recorded its key and dismiss group globally, so another page that attached the same key or reused the group name lost its chip too. Record each dismissal against the URL path it happened on and filter by the current path. Co-Authored-By: Claude Opus 5.5 --- .../references/injecting-context.md | 2 +- products/posthog_ai/frontend/AGENTS.md | 4 +- products/posthog_ai/frontend/README.md | 5 +- .../logics/attachedContextLogic.test.ts | 23 ++++- .../frontend/logics/attachedContextLogic.ts | 95 ++++++++++++++----- .../frontend/logics/contextPickerLogic.ts | 1 + .../posthog_ai/frontend/types/contextTypes.ts | 2 +- .../TemplateLibrary/newTemplateAgentLogic.ts | 2 + 8 files changed, 104 insertions(+), 30 deletions(-) diff --git a/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md b/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md index f4597768c7a3..642b48c8e04b 100644 --- a/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md +++ b/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md @@ -76,6 +76,6 @@ Non-`instructions` items land in ``, behind hardening Deduping is automatic and **task-scoped**, covering the whole resume chain of runs, in two layers: keys marked right after each send, and the durable layer derived from context blocks found in replayed history (so it survives reloads, other tabs, and other users' sessions). `text` items are never deduped — repeated text is intentional. -Chips render **all** of `contextItems`, whatever provider contributed them. Closing a chip your provider owns dispatches `dismissContext(key)`, and dismissal **survives re-registration** — a scene bridge that upserts on every read must not resurrect a chip the user closed. This is what `dismissGroup` is for: pair a visible chip with the hidden payload items it stands for, so closing the chip actually detaches the payload instead of only hiding the chip. +Chips render **all** of `contextItems`, whatever provider contributed them. Closing a chip your provider owns dispatches `dismissContext(key)`, and dismissal **survives re-registration** — a scene bridge that upserts on every read must not resurrect a chip the user closed. Dismissal is scoped to the page (URL path) it happened on, so you do not need a unique key or group name per page. This is what `dismissGroup` is for: pair a visible chip with the hidden payload items it stands for, so closing the chip actually detaches the payload instead of only hiding the chip. Users can also attach context themselves through the composer's `@` affordance (`AttachedContextBar` from `api/primitives`, backed by `contextPickerLogic` as the `user-picker` provider). Selections become flat refs via `taxonomicItemToAttachedContext` — again no entity loading. diff --git a/products/posthog_ai/frontend/AGENTS.md b/products/posthog_ai/frontend/AGENTS.md index 8dc57f8cc1b1..66d2f452d61f 100644 --- a/products/posthog_ai/frontend/AGENTS.md +++ b/products/posthog_ai/frontend/AGENTS.md @@ -187,7 +187,9 @@ composer's @-affordance. `AttachedContextBar` (Tier 2, drop into `Composer.Heade render **all** of `contextItems`; closing a picked chip removes it from the picker, closing any other provider's chip dispatches `attachedContextLogic.dismissContext(key)`. Dismissal filters the key out of `contextItems` and **survives provider re-registration** (the scene bridge upserts on every scene read — a -closed chip must not resurrect); re-picking the same item `undismissContext`s it. +closed chip must not resurrect); re-picking the same item `undismissContext`s it. Dismissals of keys and +groups are **scoped to the page** (URL path) they happened on, so another page that attaches the same key or +reuses the group name keeps its chips. **Tool-stream events (`logics/toolStreamEventsLogic.ts`):** a global bus `runStreamLogic` publishes tool-call lifecycle events to — `phase: started/updated/completed/failed`, with `toolName` **resolved** via diff --git a/products/posthog_ai/frontend/README.md b/products/posthog_ai/frontend/README.md index 355321267561..a63422185f66 100644 --- a/products/posthog_ai/frontend/README.md +++ b/products/posthog_ai/frontend/README.md @@ -288,8 +288,9 @@ The @-button opens a `TaxonomicPopover` over events/actions/insights/dashboards/ issues; a selection becomes a flat `{ type, key, label }` ref in `contextPickerLogic` (`api/logics`) — no entity loading, the agent fetches details. The chips render **everything** in `contextItems`, whatever provider contributed it. Closing a chip the picker owns removes the pick; closing any other provider's chip -dismisses that key (`attachedContextLogic.dismissContext`), which sticks even while the provider keeps -re-registering the item — re-picking it through the popover restores it. +dismisses that key (`attachedContextLogic.dismissContext`) on the current page, which sticks even while the +provider keeps re-registering the item — re-picking it through the popover restores it. Other pages keep +showing the same key. ### React to the agent invoking a tool diff --git a/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts b/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts index 823687bacc6b..a8e1054d27be 100644 --- a/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts +++ b/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts @@ -1,8 +1,9 @@ +import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' -import type { AttachedContextItem } from '../types/contextTypes' +import { type AttachedContextItem, attachedContextItemKey } from '../types/contextTypes' import { attachedContextLogic } from './attachedContextLogic' describe('attachedContextLogic', () => { @@ -110,4 +111,24 @@ describe('attachedContextLogic', () => { logic.actions.undismissContext('hog_flow:1', 'g') }).toMatchValues({ contextItems: groupItems('v2') }) }) + + it.each([ + { name: 'an ungrouped chip', chip: { type: 'skill', key: 'shared' } as AttachedContextItem }, + { name: 'a grouped chip', chip: { type: 'insight', key: 'a', dismissGroup: 'viewer' } as AttachedContextItem }, + ])('dismissing $name hides it only on the page it was closed on', async ({ chip }) => { + const otherPageChip = { ...chip, key: chip.dismissGroup ? 'b' : chip.key } + router.actions.push('/page-one') + logic.actions.registerContext('scene', [chip]) + logic.actions.dismissContext(attachedContextItemKey(chip), chip.dismissGroup) + + await expectLogic(logic, () => { + router.actions.push('/page-two') + logic.actions.registerContext('scene', [otherPageChip]) + }).toMatchValues({ contextItems: [otherPageChip] }) + + await expectLogic(logic, () => { + router.actions.push('/page-one') + logic.actions.registerContext('scene', [chip]) + }).toMatchValues({ contextItems: [] }) + }) }) diff --git a/products/posthog_ai/frontend/logics/attachedContextLogic.ts b/products/posthog_ai/frontend/logics/attachedContextLogic.ts index 08eb7b4af3d5..5749b3710310 100644 --- a/products/posthog_ai/frontend/logics/attachedContextLogic.ts +++ b/products/posthog_ai/frontend/logics/attachedContextLogic.ts @@ -1,12 +1,41 @@ -import { MakeLogicType, actions, kea, path, reducers, selectors } from 'kea' +import { MakeLogicType, actions, connect, kea, path, reducers, selectors } from 'kea' +import { router } from 'kea-router' import { type AttachedContextItem, attachedContextItemKey } from '../types/contextTypes' +type DismissalsByPage = Record> + +const NO_DISMISSALS: Record = {} + +function currentPage(): string { + return router.values.location.pathname +} + +function addDismissal(state: DismissalsByPage, page: string, name: string): DismissalsByPage { + return { ...state, [page]: { ...state[page], [name]: true as const } } +} + +function removeDismissal(state: DismissalsByPage, page: string, name: string): DismissalsByPage { + const pageDismissals = state[page] + if (!pageDismissals || !(name in pageDismissals)) { + return state + } + const { [name]: _dropped, ...rest } = pageDismissals + return { ...state, [page]: rest } +} + // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface attachedContextLogicValues { + location: { + pathname: string + search: string + hash: string + } // router contextItems: AttachedContextItem[] dismissedGroups: Record + dismissedGroupsByPage: DismissalsByPage dismissedKeys: Record + dismissedKeysByPage: DismissalsByPage hasContext: boolean providers: Record seenContextLinesByTask: Record @@ -24,6 +53,7 @@ export interface attachedContextLogicActions { ) => { dismissGroup: string | undefined key: string + page: string } markContextLinesSeen: ( taskId: string, @@ -52,12 +82,29 @@ export interface attachedContextLogicActions { ) => { dismissGroup: string | undefined key: string + page: string } } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface attachedContextLogicMeta { __keaTypeGenInternalSelectorTypes: { + dismissedKeys: ( + dismissedKeysByPage: DismissalsByPage, + location: { + hash: string + pathname: string + search: string + } + ) => Record + dismissedGroups: ( + dismissedGroupsByPage: DismissalsByPage, + location: { + hash: string + pathname: string + search: string + } + ) => Record contextItems: ( providers: Record, dismissedKeys: Record, @@ -99,6 +146,7 @@ export type attachedContextLogicType = MakeLogicType< */ export const attachedContextLogic = kea([ path(['products', 'posthog_ai', 'frontend', 'logics', 'attachedContextLogic']), + connect(() => ({ values: [router, ['location']] })), actions({ /** Idempotent upsert — re-register the same `providerId` to update its items. */ @@ -115,8 +163,8 @@ export const attachedContextLogic = kea([ * also hides every item sharing that group, including future re-registrations whose value * (and therefore key) differs, which is how a chip detaches the hidden payload it stands for. */ - dismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup }), - undismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup }), + dismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup, page: currentPage() }), + undismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup, page: currentPage() }), }), reducers({ @@ -170,36 +218,35 @@ export const attachedContextLogic = kea([ }, }, ], - dismissedKeys: [ - {} as Record, + dismissedKeysByPage: [ + {} as DismissalsByPage, { - dismissContext: (state, { key }) => ({ ...state, [key]: true as const }), - undismissContext: (state, { key }) => { - if (!(key in state)) { - return state - } - const { [key]: _dropped, ...rest } = state - return rest - }, + dismissContext: (state, { key, page }) => addDismissal(state, page, key), + undismissContext: (state, { key, page }) => removeDismissal(state, page, key), }, ], - dismissedGroups: [ - {} as Record, + dismissedGroupsByPage: [ + {} as DismissalsByPage, { - dismissContext: (state, { dismissGroup }) => - dismissGroup ? { ...state, [dismissGroup]: true as const } : state, - undismissContext: (state, { dismissGroup }) => { - if (!dismissGroup || !(dismissGroup in state)) { - return state - } - const { [dismissGroup]: _dropped, ...rest } = state - return rest - }, + dismissContext: (state, { dismissGroup, page }) => + dismissGroup ? addDismissal(state, page, dismissGroup) : state, + undismissContext: (state, { dismissGroup, page }) => + dismissGroup ? removeDismissal(state, page, dismissGroup) : state, }, ], }), selectors({ + dismissedKeys: [ + (s) => [s.dismissedKeysByPage, s.location], + (dismissedKeysByPage: DismissalsByPage, location: { pathname: string }): Record => + dismissedKeysByPage[location.pathname] ?? NO_DISMISSALS, + ], + dismissedGroups: [ + (s) => [s.dismissedGroupsByPage, s.location], + (dismissedGroupsByPage: DismissalsByPage, location: { pathname: string }): Record => + dismissedGroupsByPage[location.pathname] ?? NO_DISMISSALS, + ], // Flattened across providers, deduped by `${type}:${key ?? value}` (first writer wins). // Items with neither `key` nor `value` carry no payload and are dropped, as are dismissed // keys and members of dismissed groups. diff --git a/products/posthog_ai/frontend/logics/contextPickerLogic.ts b/products/posthog_ai/frontend/logics/contextPickerLogic.ts index c15c874b0b04..d21e133c9f43 100644 --- a/products/posthog_ai/frontend/logics/contextPickerLogic.ts +++ b/products/posthog_ai/frontend/logics/contextPickerLogic.ts @@ -91,6 +91,7 @@ export interface contextPickerLogicActions { ) => { dismissGroup: string | undefined key: string + page: string } // attachedContextLogic handleTaxonomicFilterChange: ( value: number | string, diff --git a/products/posthog_ai/frontend/types/contextTypes.ts b/products/posthog_ai/frontend/types/contextTypes.ts index 48a60ba49242..9deebd5246eb 100644 --- a/products/posthog_ai/frontend/types/contextTypes.ts +++ b/products/posthog_ai/frontend/types/contextTypes.ts @@ -32,7 +32,7 @@ export interface AttachedContextItem { * detaches the group's hidden items, so a chip never claims control it doesn't have. Dismissal * is recorded against this stable name rather than the dedupe key, which makes it stick for * hidden items whose value (and therefore key) changes on every re-registration, e.g. live - * editor state. + * editor state. It is also scoped to the page it happened on, so pages can reuse a group name. */ dismissGroup?: string } diff --git a/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts b/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts index 372a617cc524..10814d104b23 100644 --- a/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts @@ -54,6 +54,7 @@ export interface newTemplateAgentLogicActions { ) => { dismissGroup: string | undefined key: string + page: string } // attachedContextLogic undismissContext: ( key: string, @@ -61,6 +62,7 @@ export interface newTemplateAgentLogicActions { ) => { dismissGroup: string | undefined key: string + page: string } // attachedContextLogic locationChanged: ({ method, From 7ec81bc079f5d4e91960a3ad3d6070b09163985c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:09:04 +0000 Subject: [PATCH 2/3] fix(posthog-ai): scope chip dismissals to the active scene Scoping a dismissal by URL path brought a closed chip back when a scene changed its path between tabs, such as a workflow's Metrics tab or an insight's edit mode, so removed context rode the next message. The store now records dismissals against a generic dismiss scope, and maxGlobalLogic, which the app shell always mounts, sets it to the active scene. A dismissal holds across a scene's tabs and entities and never reaches another scene. The store stays free of sceneLogic, so mounting it does not mount the scene router. Co-Authored-By: Claude Opus 5.5 --- .../references/injecting-context.md | 2 +- .../src/scenes/max/maxGlobalLogic.test.ts | 12 ++ frontend/src/scenes/max/maxGlobalLogic.tsx | 37 +++++- products/posthog_ai/frontend/AGENTS.md | 5 +- products/posthog_ai/frontend/README.md | 4 +- .../logics/attachedContextLogic.test.ts | 15 ++- .../frontend/logics/attachedContextLogic.ts | 107 +++++++++--------- .../frontend/logics/contextPickerLogic.ts | 2 +- .../posthog_ai/frontend/types/contextTypes.ts | 2 +- .../TemplateLibrary/newTemplateAgentLogic.ts | 4 +- 10 files changed, 116 insertions(+), 74 deletions(-) diff --git a/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md b/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md index 642b48c8e04b..ce2864f78f6b 100644 --- a/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md +++ b/.agents/skills/integrating-with-posthog-ai/references/injecting-context.md @@ -76,6 +76,6 @@ Non-`instructions` items land in ``, behind hardening Deduping is automatic and **task-scoped**, covering the whole resume chain of runs, in two layers: keys marked right after each send, and the durable layer derived from context blocks found in replayed history (so it survives reloads, other tabs, and other users' sessions). `text` items are never deduped — repeated text is intentional. -Chips render **all** of `contextItems`, whatever provider contributed them. Closing a chip your provider owns dispatches `dismissContext(key)`, and dismissal **survives re-registration** — a scene bridge that upserts on every read must not resurrect a chip the user closed. Dismissal is scoped to the page (URL path) it happened on, so you do not need a unique key or group name per page. This is what `dismissGroup` is for: pair a visible chip with the hidden payload items it stands for, so closing the chip actually detaches the payload instead of only hiding the chip. +Chips render **all** of `contextItems`, whatever provider contributed them. Closing a chip your provider owns dispatches `dismissContext(key)`, and dismissal **survives re-registration** — a scene bridge that upserts on every read must not resurrect a chip the user closed. Dismissal is scoped to the active scene, so another scene can reuse a key or group name. Within one scene it holds across tabs and entities: give each entity its own group name when one entity's dismissal must not hide another's chip. This is what `dismissGroup` is for: pair a visible chip with the hidden payload items it stands for, so closing the chip actually detaches the payload instead of only hiding the chip. Users can also attach context themselves through the composer's `@` affordance (`AttachedContextBar` from `api/primitives`, backed by `contextPickerLogic` as the `user-picker` provider). Selections become flat refs via `taxonomicItemToAttachedContext` — again no entity loading. diff --git a/frontend/src/scenes/max/maxGlobalLogic.test.ts b/frontend/src/scenes/max/maxGlobalLogic.test.ts index 5c108c22e8c8..3e2473975f83 100644 --- a/frontend/src/scenes/max/maxGlobalLogic.test.ts +++ b/frontend/src/scenes/max/maxGlobalLogic.test.ts @@ -5,11 +5,15 @@ import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' +import { sceneLogic } from 'scenes/sceneLogic' +import { emptySceneParams } from 'scenes/scenes' +import { Scene } from 'scenes/sceneTypes' import { aiConsentLogic } from 'scenes/settings/organization/aiConsentLogic' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' +import { attachedContextLogic } from 'products/posthog_ai/frontend/logics/attachedContextLogic' import { tasksLogic } from 'products/posthog_ai/frontend/logics/tasksLogic' import { TOOL_DEFINITIONS, ToolDefinition } from './max-constants' @@ -60,6 +64,14 @@ describe('maxGlobalLogic', () => { expect(tasksLogic.findMounted()).toBeNull() }) + it.each([Scene.Insight, Scene.Dashboard])('scopes context chip dismissals to the %s scene', async (scene) => { + await expectLogic(logic, () => { + sceneLogic.actions.setScene(scene, undefined, emptySceneParams) + }).toFinishAllListeners() + + expect(attachedContextLogic.values.dismissScope).toEqual(scene) + }) + // Opening a conversation in the side panel must surface it in the side panel chat without // replacing the main content. The side panel floats over whatever scene you're on; the rendered // scene is chosen by the route, so the page (insight, survey, …) must stay put. diff --git a/frontend/src/scenes/max/maxGlobalLogic.tsx b/frontend/src/scenes/max/maxGlobalLogic.tsx index e98134f69715..214801d21614 100644 --- a/frontend/src/scenes/max/maxGlobalLogic.tsx +++ b/frontend/src/scenes/max/maxGlobalLogic.tsx @@ -19,6 +19,7 @@ import { urls } from 'scenes/urls' import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic' import { Conversation, ConversationDetail, SidePanelTab } from '~/types' +import { attachedContextLogic } from 'products/posthog_ai/frontend/api/logics' import { conversationsDestroy } from 'products/posthog_ai/frontend/generated/api' import { taskEventsLogic } from 'products/posthog_ai/frontend/logics/taskEventsLogic' import { tasksLogic } from 'products/posthog_ai/frontend/logics/tasksLogic' @@ -140,6 +141,9 @@ export interface maxGlobalLogicActions { requestAiAccess: () => { value: true } // aiConsentLogic + setDismissScope: (scope: string) => { + scope: string + } // attachedContextLogic locationChanged: ({ method, pathname, @@ -161,6 +165,30 @@ export interface maxGlobalLogicActions { searchParams: Record url: string } // router + setExportedScene: ( + exportedScene: import('../sceneTypes').SceneExport, + sceneId: string, + sceneKey: string | undefined, + params: import('../sceneTypes').SceneParams + ) => { + exportedScene: import('../sceneTypes').SceneExport + params: import('../sceneTypes').SceneParams + sceneId: string + sceneKey: string | undefined + } // sceneLogic + setScene: ( + sceneId: string, + sceneKey: string | undefined, + params: import('../sceneTypes').SceneParams, + scrollToTop?: boolean | undefined, + exportedScene?: import('../sceneTypes').SceneExport | undefined + ) => { + exportedScene: import('../sceneTypes').SceneExport | undefined + params: import('../sceneTypes').SceneParams + sceneId: string + sceneKey: string | undefined + scrollToTop: boolean + } // sceneLogic openSidePanel: ( tab: SidePanelTab, options?: string | undefined @@ -296,6 +324,10 @@ export const maxGlobalLogic = kea([ actions: [ router, ['locationChanged'], + sceneLogic, + ['setScene', 'setExportedScene'], + attachedContextLogic, + ['setDismissScope'], sidePanelStateLogic, ['openSidePanel'], aiConsentLogic, @@ -393,6 +425,8 @@ export const maxGlobalLogic = kea([ ], }), listeners(({ actions, values }) => ({ + setScene: ({ sceneId }) => actions.setDismissScope(sceneId), + setExportedScene: ({ sceneId }) => actions.setDismissScope(sceneId), setPhaiViewMode: ({ mode }) => { // The task list only loads on mount. A chat made on the legacy view has a task copy by the // time the user switches, and the merged list shows it as that task once it is loaded. @@ -454,8 +488,9 @@ export const maxGlobalLogic = kea([ } }, })), - afterMount(({ actions }) => { + afterMount(({ actions, values }) => { actions.loadConversationHistory() + actions.setDismissScope(values.sceneId ?? '') }), selectors({ diff --git a/products/posthog_ai/frontend/AGENTS.md b/products/posthog_ai/frontend/AGENTS.md index 66d2f452d61f..74ff7e447afb 100644 --- a/products/posthog_ai/frontend/AGENTS.md +++ b/products/posthog_ai/frontend/AGENTS.md @@ -188,8 +188,9 @@ render **all** of `contextItems`; closing a picked chip removes it from the pick provider's chip dispatches `attachedContextLogic.dismissContext(key)`. Dismissal filters the key out of `contextItems` and **survives provider re-registration** (the scene bridge upserts on every scene read — a closed chip must not resurrect); re-picking the same item `undismissContext`s it. Dismissals of keys and -groups are **scoped to the page** (URL path) they happened on, so another page that attaches the same key or -reuses the group name keeps its chips. +groups are **scoped** by `dismissScope`, which the host sets with `setDismissScope` (the app sets the active +scene, in `maxGlobalLogic`): they hold across that scene's tabs and entities, and another scene that attaches +the same key or reuses the group name keeps its chips. **Tool-stream events (`logics/toolStreamEventsLogic.ts`):** a global bus `runStreamLogic` publishes tool-call lifecycle events to — `phase: started/updated/completed/failed`, with `toolName` **resolved** via diff --git a/products/posthog_ai/frontend/README.md b/products/posthog_ai/frontend/README.md index a63422185f66..6ecbf223bffa 100644 --- a/products/posthog_ai/frontend/README.md +++ b/products/posthog_ai/frontend/README.md @@ -288,8 +288,8 @@ The @-button opens a `TaxonomicPopover` over events/actions/insights/dashboards/ issues; a selection becomes a flat `{ type, key, label }` ref in `contextPickerLogic` (`api/logics`) — no entity loading, the agent fetches details. The chips render **everything** in `contextItems`, whatever provider contributed it. Closing a chip the picker owns removes the pick; closing any other provider's chip -dismisses that key (`attachedContextLogic.dismissContext`) on the current page, which sticks even while the -provider keeps re-registering the item — re-picking it through the popover restores it. Other pages keep +dismisses that key (`attachedContextLogic.dismissContext`) in the active scene, which sticks even while the +provider keeps re-registering the item — re-picking it through the popover restores it. Other scenes keep showing the same key. ### React to the agent invoking a tool diff --git a/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts b/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts index a8e1054d27be..729b52506e47 100644 --- a/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts +++ b/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts @@ -1,4 +1,3 @@ -import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' @@ -115,19 +114,19 @@ describe('attachedContextLogic', () => { it.each([ { name: 'an ungrouped chip', chip: { type: 'skill', key: 'shared' } as AttachedContextItem }, { name: 'a grouped chip', chip: { type: 'insight', key: 'a', dismissGroup: 'viewer' } as AttachedContextItem }, - ])('dismissing $name hides it only on the page it was closed on', async ({ chip }) => { - const otherPageChip = { ...chip, key: chip.dismissGroup ? 'b' : chip.key } - router.actions.push('/page-one') + ])('dismissing $name hides it only in the scope it was closed in', async ({ chip }) => { + const otherScopeChip = { ...chip, key: chip.dismissGroup ? 'b' : chip.key } + logic.actions.setDismissScope('Workflow') logic.actions.registerContext('scene', [chip]) logic.actions.dismissContext(attachedContextItemKey(chip), chip.dismissGroup) await expectLogic(logic, () => { - router.actions.push('/page-two') - logic.actions.registerContext('scene', [otherPageChip]) - }).toMatchValues({ contextItems: [otherPageChip] }) + logic.actions.setDismissScope('Insight') + logic.actions.registerContext('scene', [otherScopeChip]) + }).toMatchValues({ contextItems: [otherScopeChip] }) await expectLogic(logic, () => { - router.actions.push('/page-one') + logic.actions.setDismissScope('Workflow') logic.actions.registerContext('scene', [chip]) }).toMatchValues({ contextItems: [] }) }) diff --git a/products/posthog_ai/frontend/logics/attachedContextLogic.ts b/products/posthog_ai/frontend/logics/attachedContextLogic.ts index 5749b3710310..c2380b142af3 100644 --- a/products/posthog_ai/frontend/logics/attachedContextLogic.ts +++ b/products/posthog_ai/frontend/logics/attachedContextLogic.ts @@ -1,41 +1,37 @@ -import { MakeLogicType, actions, connect, kea, path, reducers, selectors } from 'kea' -import { router } from 'kea-router' +import { MakeLogicType, actions, kea, path, reducers, selectors } from 'kea' import { type AttachedContextItem, attachedContextItemKey } from '../types/contextTypes' -type DismissalsByPage = Record> +type DismissalsByScope = Record> +const NO_SCOPE: string = '' const NO_DISMISSALS: Record = {} -function currentPage(): string { - return router.values.location.pathname +function currentDismissScope(): string { + return attachedContextLogic.findMounted()?.values.dismissScope ?? NO_SCOPE } -function addDismissal(state: DismissalsByPage, page: string, name: string): DismissalsByPage { - return { ...state, [page]: { ...state[page], [name]: true as const } } +function addDismissal(state: DismissalsByScope, scope: string, name: string): DismissalsByScope { + return { ...state, [scope]: { ...state[scope], [name]: true as const } } } -function removeDismissal(state: DismissalsByPage, page: string, name: string): DismissalsByPage { - const pageDismissals = state[page] - if (!pageDismissals || !(name in pageDismissals)) { +function removeDismissal(state: DismissalsByScope, scope: string, name: string): DismissalsByScope { + const scopeDismissals = state[scope] + if (!scopeDismissals || !(name in scopeDismissals)) { return state } - const { [name]: _dropped, ...rest } = pageDismissals - return { ...state, [page]: rest } + const { [name]: _dropped, ...rest } = scopeDismissals + return { ...state, [scope]: rest } } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface attachedContextLogicValues { - location: { - pathname: string - search: string - hash: string - } // router contextItems: AttachedContextItem[] + dismissScope: string dismissedGroups: Record - dismissedGroupsByPage: DismissalsByPage + dismissedGroupsByScope: DismissalsByScope dismissedKeys: Record - dismissedKeysByPage: DismissalsByPage + dismissedKeysByScope: DismissalsByScope hasContext: boolean providers: Record seenContextLinesByTask: Record @@ -53,7 +49,7 @@ export interface attachedContextLogicActions { ) => { dismissGroup: string | undefined key: string - page: string + scope: string } markContextLinesSeen: ( taskId: string, @@ -76,35 +72,24 @@ export interface attachedContextLogicActions { items: AttachedContextItem[] providerId: string } + setDismissScope: (scope: string) => { + scope: string + } undismissContext: ( key: string, dismissGroup?: string ) => { dismissGroup: string | undefined key: string - page: string + scope: string } } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface attachedContextLogicMeta { __keaTypeGenInternalSelectorTypes: { - dismissedKeys: ( - dismissedKeysByPage: DismissalsByPage, - location: { - hash: string - pathname: string - search: string - } - ) => Record - dismissedGroups: ( - dismissedGroupsByPage: DismissalsByPage, - location: { - hash: string - pathname: string - search: string - } - ) => Record + dismissedKeys: (dismissedKeysByScope: DismissalsByScope, dismissScope: string) => Record + dismissedGroups: (dismissedGroupsByScope: DismissalsByScope, dismissScope: string) => Record contextItems: ( providers: Record, dismissedKeys: Record, @@ -146,7 +131,6 @@ export type attachedContextLogicType = MakeLogicType< */ export const attachedContextLogic = kea([ path(['products', 'posthog_ai', 'frontend', 'logics', 'attachedContextLogic']), - connect(() => ({ values: [router, ['location']] })), actions({ /** Idempotent upsert — re-register the same `providerId` to update its items. */ @@ -163,11 +147,22 @@ export const attachedContextLogic = kea([ * also hides every item sharing that group, including future re-registrations whose value * (and therefore key) differs, which is how a chip detaches the hidden payload it stands for. */ - dismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup, page: currentPage() }), - undismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup, page: currentPage() }), + dismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup, scope: currentDismissScope() }), + undismissContext: (key: string, dismissGroup?: string) => ({ + key, + dismissGroup, + scope: currentDismissScope(), + }), + setDismissScope: (scope: string) => ({ scope }), }), reducers({ + dismissScope: [ + NO_SCOPE, + { + setDismissScope: (_, { scope }) => scope, + }, + ], providers: [ {} as Record, { @@ -218,34 +213,34 @@ export const attachedContextLogic = kea([ }, }, ], - dismissedKeysByPage: [ - {} as DismissalsByPage, + dismissedKeysByScope: [ + {} as DismissalsByScope, { - dismissContext: (state, { key, page }) => addDismissal(state, page, key), - undismissContext: (state, { key, page }) => removeDismissal(state, page, key), + dismissContext: (state, { key, scope }) => addDismissal(state, scope, key), + undismissContext: (state, { key, scope }) => removeDismissal(state, scope, key), }, ], - dismissedGroupsByPage: [ - {} as DismissalsByPage, + dismissedGroupsByScope: [ + {} as DismissalsByScope, { - dismissContext: (state, { dismissGroup, page }) => - dismissGroup ? addDismissal(state, page, dismissGroup) : state, - undismissContext: (state, { dismissGroup, page }) => - dismissGroup ? removeDismissal(state, page, dismissGroup) : state, + dismissContext: (state, { dismissGroup, scope }) => + dismissGroup ? addDismissal(state, scope, dismissGroup) : state, + undismissContext: (state, { dismissGroup, scope }) => + dismissGroup ? removeDismissal(state, scope, dismissGroup) : state, }, ], }), selectors({ dismissedKeys: [ - (s) => [s.dismissedKeysByPage, s.location], - (dismissedKeysByPage: DismissalsByPage, location: { pathname: string }): Record => - dismissedKeysByPage[location.pathname] ?? NO_DISMISSALS, + (s) => [s.dismissedKeysByScope, s.dismissScope], + (dismissedKeysByScope: DismissalsByScope, dismissScope: string): Record => + dismissedKeysByScope[dismissScope] ?? NO_DISMISSALS, ], dismissedGroups: [ - (s) => [s.dismissedGroupsByPage, s.location], - (dismissedGroupsByPage: DismissalsByPage, location: { pathname: string }): Record => - dismissedGroupsByPage[location.pathname] ?? NO_DISMISSALS, + (s) => [s.dismissedGroupsByScope, s.dismissScope], + (dismissedGroupsByScope: DismissalsByScope, dismissScope: string): Record => + dismissedGroupsByScope[dismissScope] ?? NO_DISMISSALS, ], // Flattened across providers, deduped by `${type}:${key ?? value}` (first writer wins). // Items with neither `key` nor `value` carry no payload and are dropped, as are dismissed diff --git a/products/posthog_ai/frontend/logics/contextPickerLogic.ts b/products/posthog_ai/frontend/logics/contextPickerLogic.ts index d21e133c9f43..ca56b6b7534f 100644 --- a/products/posthog_ai/frontend/logics/contextPickerLogic.ts +++ b/products/posthog_ai/frontend/logics/contextPickerLogic.ts @@ -91,7 +91,7 @@ export interface contextPickerLogicActions { ) => { dismissGroup: string | undefined key: string - page: string + scope: string } // attachedContextLogic handleTaxonomicFilterChange: ( value: number | string, diff --git a/products/posthog_ai/frontend/types/contextTypes.ts b/products/posthog_ai/frontend/types/contextTypes.ts index 9deebd5246eb..b21c514b35bd 100644 --- a/products/posthog_ai/frontend/types/contextTypes.ts +++ b/products/posthog_ai/frontend/types/contextTypes.ts @@ -32,7 +32,7 @@ export interface AttachedContextItem { * detaches the group's hidden items, so a chip never claims control it doesn't have. Dismissal * is recorded against this stable name rather than the dedupe key, which makes it stick for * hidden items whose value (and therefore key) changes on every re-registration, e.g. live - * editor state. It is also scoped to the page it happened on, so pages can reuse a group name. + * editor state. It is scoped to the active scene, so scenes can reuse a group name. */ dismissGroup?: string } diff --git a/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts b/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts index 10814d104b23..4478f1241f26 100644 --- a/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts +++ b/products/workflows/frontend/TemplateLibrary/newTemplateAgentLogic.ts @@ -54,7 +54,7 @@ export interface newTemplateAgentLogicActions { ) => { dismissGroup: string | undefined key: string - page: string + scope: string } // attachedContextLogic undismissContext: ( key: string, @@ -62,7 +62,7 @@ export interface newTemplateAgentLogicActions { ) => { dismissGroup: string | undefined key: string - page: string + scope: string } // attachedContextLogic locationChanged: ({ method, From d1d8accca6f54f8516f68d27680e8749413ec9e6 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:46:08 +0000 Subject: [PATCH 3/3] fix(posthog-ai): follow the active scene with one subscription maxGlobalLogic now sets the dismiss scope from a sceneId subscription instead of two scene action listeners and a mount seed. sceneId already changes on both setScene and setExportedScene, and the subscription also fires on mount. The scope test now returns with a re-keyed member of the dismissed group, so it fails if the group dismissal stops holding in its own scene. The Max test imports the store through the api/logics facade. Co-Authored-By: Claude Opus 5.5 --- .../src/scenes/max/maxGlobalLogic.test.ts | 2 +- frontend/src/scenes/max/maxGlobalLogic.tsx | 35 +++---------------- .../logics/attachedContextLogic.test.ts | 3 +- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/frontend/src/scenes/max/maxGlobalLogic.test.ts b/frontend/src/scenes/max/maxGlobalLogic.test.ts index 3e2473975f83..286440b4692f 100644 --- a/frontend/src/scenes/max/maxGlobalLogic.test.ts +++ b/frontend/src/scenes/max/maxGlobalLogic.test.ts @@ -13,7 +13,7 @@ import { aiConsentLogic } from 'scenes/settings/organization/aiConsentLogic' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' -import { attachedContextLogic } from 'products/posthog_ai/frontend/logics/attachedContextLogic' +import { attachedContextLogic } from 'products/posthog_ai/frontend/api/logics' import { tasksLogic } from 'products/posthog_ai/frontend/logics/tasksLogic' import { TOOL_DEFINITIONS, ToolDefinition } from './max-constants' diff --git a/frontend/src/scenes/max/maxGlobalLogic.tsx b/frontend/src/scenes/max/maxGlobalLogic.tsx index 214801d21614..dcd9480fe851 100644 --- a/frontend/src/scenes/max/maxGlobalLogic.tsx +++ b/frontend/src/scenes/max/maxGlobalLogic.tsx @@ -2,6 +2,7 @@ import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, redu import { loaders } from 'kea-loaders' import { router } from 'kea-router' import type { LocationChangedPayload } from 'kea-router/lib/types' +import { subscriptions } from 'kea-subscriptions' import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' @@ -165,30 +166,6 @@ export interface maxGlobalLogicActions { searchParams: Record url: string } // router - setExportedScene: ( - exportedScene: import('../sceneTypes').SceneExport, - sceneId: string, - sceneKey: string | undefined, - params: import('../sceneTypes').SceneParams - ) => { - exportedScene: import('../sceneTypes').SceneExport - params: import('../sceneTypes').SceneParams - sceneId: string - sceneKey: string | undefined - } // sceneLogic - setScene: ( - sceneId: string, - sceneKey: string | undefined, - params: import('../sceneTypes').SceneParams, - scrollToTop?: boolean | undefined, - exportedScene?: import('../sceneTypes').SceneExport | undefined - ) => { - exportedScene: import('../sceneTypes').SceneExport | undefined - params: import('../sceneTypes').SceneParams - sceneId: string - sceneKey: string | undefined - scrollToTop: boolean - } // sceneLogic openSidePanel: ( tab: SidePanelTab, options?: string | undefined @@ -324,8 +301,6 @@ export const maxGlobalLogic = kea([ actions: [ router, ['locationChanged'], - sceneLogic, - ['setScene', 'setExportedScene'], attachedContextLogic, ['setDismissScope'], sidePanelStateLogic, @@ -425,8 +400,6 @@ export const maxGlobalLogic = kea([ ], }), listeners(({ actions, values }) => ({ - setScene: ({ sceneId }) => actions.setDismissScope(sceneId), - setExportedScene: ({ sceneId }) => actions.setDismissScope(sceneId), setPhaiViewMode: ({ mode }) => { // The task list only loads on mount. A chat made on the legacy view has a task copy by the // time the user switches, and the merged list shows it as that task once it is loaded. @@ -488,10 +461,12 @@ export const maxGlobalLogic = kea([ } }, })), - afterMount(({ actions, values }) => { + afterMount(({ actions }) => { actions.loadConversationHistory() - actions.setDismissScope(values.sceneId ?? '') }), + subscriptions(({ actions }) => ({ + sceneId: (sceneId: string | null) => actions.setDismissScope(sceneId ?? ''), + })), selectors({ currentConversationId: [ diff --git a/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts b/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts index 729b52506e47..45edef82f423 100644 --- a/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts +++ b/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts @@ -116,6 +116,7 @@ describe('attachedContextLogic', () => { { name: 'a grouped chip', chip: { type: 'insight', key: 'a', dismissGroup: 'viewer' } as AttachedContextItem }, ])('dismissing $name hides it only in the scope it was closed in', async ({ chip }) => { const otherScopeChip = { ...chip, key: chip.dismissGroup ? 'b' : chip.key } + const returningChip = { ...chip, key: chip.dismissGroup ? 'c' : chip.key } logic.actions.setDismissScope('Workflow') logic.actions.registerContext('scene', [chip]) logic.actions.dismissContext(attachedContextItemKey(chip), chip.dismissGroup) @@ -127,7 +128,7 @@ describe('attachedContextLogic', () => { await expectLogic(logic, () => { logic.actions.setDismissScope('Workflow') - logic.actions.registerContext('scene', [chip]) + logic.actions.registerContext('scene', [returningChip]) }).toMatchValues({ contextItems: [] }) }) })