Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,6 @@ Non-`instructions` items land in `<posthog_untrusted_context>`, 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.
12 changes: 12 additions & 0 deletions frontend/src/scenes/max/maxGlobalLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/scenes/max/maxGlobalLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -140,6 +142,9 @@ export interface maxGlobalLogicActions {
requestAiAccess: () => {
value: true
} // aiConsentLogic
setDismissScope: (scope: string) => {
scope: string
} // attachedContextLogic
locationChanged: ({
method,
pathname,
Expand Down Expand Up @@ -296,6 +301,8 @@ export const maxGlobalLogic = kea<maxGlobalLogicType>([
actions: [
router,
['locationChanged'],
attachedContextLogic,
['setDismissScope'],
sidePanelStateLogic,
['openSidePanel'],
aiConsentLogic,
Expand Down Expand Up @@ -457,6 +464,9 @@ export const maxGlobalLogic = kea<maxGlobalLogicType>([
afterMount(({ actions }) => {
actions.loadConversationHistory()
}),
subscriptions(({ actions }) => ({
sceneId: (sceneId: string | null) => actions.setDismissScope(sceneId ?? ''),
})),

selectors({
currentConversationId: [
Expand Down
5 changes: 4 additions & 1 deletion products/posthog_ai/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions products/posthog_ai/frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion products/posthog_ai/frontend/logics/attachedContextLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: [] })
})
})
88 changes: 65 additions & 23 deletions products/posthog_ai/frontend/logics/attachedContextLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,36 @@ import { MakeLogicType, actions, kea, path, reducers, selectors } from 'kea'

import { type AttachedContextItem, attachedContextItemKey } from '../types/contextTypes'

type DismissalsByScope = Record<string, Record<string, true>>

const NO_SCOPE: string = ''
const NO_DISMISSALS: Record<string, true> = {}

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<string, true>
dismissedGroupsByScope: DismissalsByScope
dismissedKeys: Record<string, true>
dismissedKeysByScope: DismissalsByScope
hasContext: boolean
providers: Record<string, AttachedContextItem[]>
seenContextLinesByTask: Record<string, string[]>
Expand All @@ -24,6 +49,7 @@ export interface attachedContextLogicActions {
) => {
dismissGroup: string | undefined
key: string
scope: string
}
markContextLinesSeen: (
taskId: string,
Expand All @@ -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<string, true>
dismissedGroups: (dismissedGroupsByScope: DismissalsByScope, dismissScope: string) => Record<string, true>
contextItems: (
providers: Record<string, AttachedContextItem[]>,
dismissedKeys: Record<string, true>,
Expand Down Expand Up @@ -115,11 +147,22 @@ export const attachedContextLogic = kea<attachedContextLogicType>([
* 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<string, AttachedContextItem[]>,
{
Expand Down Expand Up @@ -170,36 +213,35 @@ export const attachedContextLogic = kea<attachedContextLogicType>([
},
},
],
dismissedKeys: [
{} as Record<string, true>,
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<string, true>,
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<string, true> =>
dismissedKeysByScope[dismissScope] ?? NO_DISMISSALS,
],
dismissedGroups: [
(s) => [s.dismissedGroupsByScope, s.dismissScope],
(dismissedGroupsByScope: DismissalsByScope, dismissScope: string): Record<string, true> =>
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.
Expand Down
1 change: 1 addition & 0 deletions products/posthog_ai/frontend/logics/contextPickerLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface contextPickerLogicActions {
) => {
dismissGroup: string | undefined
key: string
scope: string
} // attachedContextLogic
handleTaxonomicFilterChange: (
value: number | string,
Expand Down
2 changes: 1 addition & 1 deletion products/posthog_ai/frontend/types/contextTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ export interface newTemplateAgentLogicActions {
) => {
dismissGroup: string | undefined
key: string
scope: string
} // attachedContextLogic
undismissContext: (
key: string,
dismissGroup?: string | undefined
) => {
dismissGroup: string | undefined
key: string
scope: string
} // attachedContextLogic
locationChanged: ({
method,
Expand Down