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..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. 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..286440b4692f 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/api/logics' 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..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' @@ -19,6 +20,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 +142,9 @@ export interface maxGlobalLogicActions { requestAiAccess: () => { value: true } // aiConsentLogic + setDismissScope: (scope: string) => { + scope: string + } // attachedContextLogic locationChanged: ({ method, pathname, @@ -296,6 +301,8 @@ export const maxGlobalLogic = kea([ actions: [ router, ['locationChanged'], + attachedContextLogic, + ['setDismissScope'], sidePanelStateLogic, ['openSidePanel'], aiConsentLogic, @@ -457,6 +464,9 @@ export const maxGlobalLogic = kea([ afterMount(({ actions }) => { actions.loadConversationHistory() }), + subscriptions(({ actions }) => ({ + sceneId: (sceneId: string | null) => actions.setDismissScope(sceneId ?? ''), + })), selectors({ currentConversationId: [ diff --git a/products/posthog_ai/frontend/AGENTS.md b/products/posthog_ai/frontend/AGENTS.md index 8dc57f8cc1b1..74ff7e447afb 100644 --- a/products/posthog_ai/frontend/AGENTS.md +++ b/products/posthog_ai/frontend/AGENTS.md @@ -187,7 +187,10 @@ 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** 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 355321267561..6ecbf223bffa 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`) 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 823687bacc6b..45edef82f423 100644 --- a/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts +++ b/products/posthog_ai/frontend/logics/attachedContextLogic.test.ts @@ -2,7 +2,7 @@ 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 +110,25 @@ 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 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) + + await expectLogic(logic, () => { + logic.actions.setDismissScope('Insight') + logic.actions.registerContext('scene', [otherScopeChip]) + }).toMatchValues({ contextItems: [otherScopeChip] }) + + await expectLogic(logic, () => { + logic.actions.setDismissScope('Workflow') + logic.actions.registerContext('scene', [returningChip]) + }).toMatchValues({ contextItems: [] }) + }) }) diff --git a/products/posthog_ai/frontend/logics/attachedContextLogic.ts b/products/posthog_ai/frontend/logics/attachedContextLogic.ts index 08eb7b4af3d5..c2380b142af3 100644 --- a/products/posthog_ai/frontend/logics/attachedContextLogic.ts +++ b/products/posthog_ai/frontend/logics/attachedContextLogic.ts @@ -2,11 +2,36 @@ import { MakeLogicType, actions, kea, path, reducers, selectors } from 'kea' import { type AttachedContextItem, attachedContextItemKey } from '../types/contextTypes' +type DismissalsByScope = Record> + +const NO_SCOPE: string = '' +const NO_DISMISSALS: Record = {} + +function currentDismissScope(): string { + return attachedContextLogic.findMounted()?.values.dismissScope ?? NO_SCOPE +} + +function addDismissal(state: DismissalsByScope, scope: string, name: string): DismissalsByScope { + return { ...state, [scope]: { ...state[scope], [name]: true as const } } +} + +function removeDismissal(state: DismissalsByScope, scope: string, name: string): DismissalsByScope { + const scopeDismissals = state[scope] + if (!scopeDismissals || !(name in scopeDismissals)) { + return state + } + 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 { contextItems: AttachedContextItem[] + dismissScope: string dismissedGroups: Record + dismissedGroupsByScope: DismissalsByScope dismissedKeys: Record + dismissedKeysByScope: DismissalsByScope hasContext: boolean providers: Record seenContextLinesByTask: Record @@ -24,6 +49,7 @@ export interface attachedContextLogicActions { ) => { dismissGroup: string | undefined key: string + scope: string } markContextLinesSeen: ( taskId: string, @@ -46,18 +72,24 @@ export interface attachedContextLogicActions { items: AttachedContextItem[] providerId: string } + setDismissScope: (scope: string) => { + scope: string + } undismissContext: ( key: string, dismissGroup?: string ) => { dismissGroup: string | undefined key: string + scope: string } } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface attachedContextLogicMeta { __keaTypeGenInternalSelectorTypes: { + dismissedKeys: (dismissedKeysByScope: DismissalsByScope, dismissScope: string) => Record + dismissedGroups: (dismissedGroupsByScope: DismissalsByScope, dismissScope: string) => Record contextItems: ( providers: Record, dismissedKeys: Record, @@ -115,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 }), - undismissContext: (key: string, dismissGroup?: string) => ({ key, dismissGroup }), + 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, { @@ -170,36 +213,35 @@ export const attachedContextLogic = kea([ }, }, ], - dismissedKeys: [ - {} as Record, + dismissedKeysByScope: [ + {} as DismissalsByScope, { - 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, scope }) => addDismissal(state, scope, key), + undismissContext: (state, { key, scope }) => removeDismissal(state, scope, key), }, ], - dismissedGroups: [ - {} as Record, + dismissedGroupsByScope: [ + {} as DismissalsByScope, { - 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, scope }) => + dismissGroup ? addDismissal(state, scope, dismissGroup) : state, + undismissContext: (state, { dismissGroup, scope }) => + dismissGroup ? removeDismissal(state, scope, dismissGroup) : state, }, ], }), selectors({ + dismissedKeys: [ + (s) => [s.dismissedKeysByScope, s.dismissScope], + (dismissedKeysByScope: DismissalsByScope, dismissScope: string): Record => + dismissedKeysByScope[dismissScope] ?? NO_DISMISSALS, + ], + dismissedGroups: [ + (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 // 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..ca56b6b7534f 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 + 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 48a60ba49242..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. + * 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 372a617cc524..4478f1241f26 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 + scope: string } // attachedContextLogic undismissContext: ( key: string, @@ -61,6 +62,7 @@ export interface newTemplateAgentLogicActions { ) => { dismissGroup: string | undefined key: string + scope: string } // attachedContextLogic locationChanged: ({ method,