Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ vi.mock('@property/editor/hooks/useAllProperties', () => ({
useAllProperties: () => () => [],
}));
vi.mock('@property/hooks', () => ({ usePropertyEntityDisplay: () => ({}) }));
vi.mock('@queries/preview', () => ({
useItemPreview: () => [() => undefined],
isAccessiblePreviewItem: () => false,
}));
vi.mock('@core/component/EntityIcon', () => ({
EntityIcon: () => null,
}));
vi.mock('@service-storage/graphql-soup', () => ({
getGraphqlSoupClient: () => ({}),
}));
Expand Down
40 changes: 36 additions & 4 deletions apps/web/src/features/activity/context/activity-context.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EntityIcon as CoreEntityIcon } from '@core/component/EntityIcon';
import { useUserId } from '@core/context/user';
import { tryMacroId, useDisplayName } from '@core/user';
import { useAllProperties } from '@property/editor/hooks/useAllProperties';
Expand All @@ -8,17 +9,19 @@ import {
firstPartyBotName,
getBotDisplayName,
} from '@queries/channel/message-sender';
import type { EntityType } from '@service-properties/generated/schemas/entityType';
import { isAccessiblePreviewItem, useItemPreview } from '@queries/preview';
import { getGraphqlSoupClient } from '@service-storage/graphql-soup';
import type { Client } from '@urql/core';
import {
type Accessor,
createContext,
createMemo,
getOwner,
type JSX,
runWithOwner,
useContext,
} from 'solid-js';
import type { ActivityDisplayEntityType } from '../core/event';

/** Resolved display for one referenced entity: name, icon, and link target. */
export type EntityDisplay = {
Expand Down Expand Up @@ -64,7 +67,7 @@ export type ActivityContext = {
/** Name, icon, and link target for a referenced entity. */
entityDisplay: (
entityId: Accessor<string>,
entityType: Accessor<EntityType>
entityType: Accessor<ActivityDisplayEntityType>
) => EntityDisplay;
/** The property definition behind a property-changed row, when known. */
propertyDefinition: (
Expand Down Expand Up @@ -107,8 +110,13 @@ function appActivityContext(): ActivityContext {
if (!list || list.isPending) return undefined;
return getBotDisplayName(`bot|${id}`, undefined, list.data ?? []);
},
entityDisplay: (entityId, entityType) =>
usePropertyEntityDisplay(entityId, entityType),
entityDisplay: (entityId, entityType) => {
const type = entityType();
if (type === 'AGENT_SESSION') {
return agentSessionEntityDisplay(entityId);
}
return usePropertyEntityDisplay(entityId, () => type);
},
propertyDefinition: (propertyId) => {
const definitions = useAllProperties();
return () => {
Expand All @@ -118,3 +126,27 @@ function appActivityContext(): ActivityContext {
},
};
}

function agentSessionEntityDisplay(entityId: Accessor<string>): EntityDisplay {
const previewWrapper = () =>
useItemPreview(() => ({
id: entityId(),
type: 'agent_session' as const,
}))[0];
const preview = createMemo(() => previewWrapper()?.());
return {
name: () => {
const item = preview();
if (!item || item.loading) return 'Loading...';
if (isAccessiblePreviewItem(item)) return item.name;
return 'Agent session';
},
icon: () => <CoreEntityIcon targetType="agent" size="xs" />,
isLoading: () => {
const item = preview();
return !item || item.loading;
},
blockOrFileType: () => 'agent',
linkParams: () => undefined,
};
}
9 changes: 7 additions & 2 deletions apps/web/src/features/activity/core/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type ActivityEntityType =
| 'email-thread'
| 'channel'
| 'user'
| 'agent-session'
| { kind: 'unsupported'; raw: string };

export type ActivityEvent = {
Expand Down Expand Up @@ -54,9 +55,12 @@ export type PropertyEntityType =
| 'CHANNEL'
| 'USER';

export function toPropertyEntityType(
/** Entity kinds the activity UI can resolve a name, icon, and link for. */
export type ActivityDisplayEntityType = PropertyEntityType | 'AGENT_SESSION';

export function toDisplayEntityType(
entityType: ActivityEntityType
): PropertyEntityType | undefined {
): ActivityDisplayEntityType | undefined {
return match(entityType)
.with({ kind: 'unsupported' }, () => undefined)
.with('document', () => 'DOCUMENT' as const)
Expand All @@ -65,5 +69,6 @@ export function toPropertyEntityType(
.with('email-thread', () => 'THREAD' as const)
.with('channel', () => 'CHANNEL' as const)
.with('user', () => 'USER' as const)
.with('agent-session', () => 'AGENT_SESSION' as const)
.exhaustive();
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ afterEach(() => {
for (const dispose of disposals.splice(0)) dispose();
});

function setup(entityType: 'DOCUMENT' | 'USER' = 'DOCUMENT') {
function setup(entityType: 'DOCUMENT' | 'USER' | 'AGENT_SESSION' = 'DOCUMENT') {
const context = createMockActivityContext();
let state!: EntityActivityState;
const dispose = createRoot((rootDispose) => {
Expand Down Expand Up @@ -77,4 +77,9 @@ describe('createEntityActivityState', () => {
expect(state.isEnabled()).toBe(false);
expect(graphql.pending).toHaveLength(0);
});

it('is enabled for agent sessions', () => {
const { state } = setup('AGENT_SESSION');
expect(state.isEnabled()).toBe(true);
});
});
11 changes: 8 additions & 3 deletions apps/web/src/features/activity/primitives/entity-activity.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { EntityType } from '@service-properties/generated/schemas/entityType';
import { type Accessor, createMemo } from 'solid-js';
import type { ActivityContext } from '../context/activity-context';
import type { ActivityEvent } from '../core/event';
import { createEntityActivityQuery } from '../queries/entity-query';
import {
createEntityActivityQuery,
type EntityActivityEntityType,
} from '../queries/entity-query';

export type EntityActivityView =
| { t: 'loading' }
Expand All @@ -22,7 +24,10 @@ export type EntityActivityState = {
*/
export function createEntityActivityState(
context: Pick<ActivityContext, 'graphql'>,
options: { entityId: Accessor<string>; entityType: Accessor<EntityType> }
options: {
entityId: Accessor<string>;
entityType: Accessor<EntityActivityEntityType>;
}
): EntityActivityState {
const query = createEntityActivityQuery(context, {
entityType: options.entityType,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/features/activity/primitives/entity-opener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ describe('createEntityOpener', () => {
expect(opener()).toBeUndefined();
});

it('resolves agent sessions as a linkable entity', () => {
const onOpen = vi.fn();
const opener = setup(createMockActivityContext(), 'agent-session', onOpen);
expect(opener()?.display.name()).toBe('Entity doc-1');
opener()?.handlers?.onClick(click(false));
expect(onOpen).toHaveBeenCalledWith({
block: 'md',
id: 'doc-1',
params: undefined,
newSplit: false,
});
});

it('does nothing when the display has no block mapping', () => {
const onOpen = vi.fn();
const context = createMockActivityContext({
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/features/activity/primitives/entity-opener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
EntityDisplay,
OpenEntityTarget,
} from '../context/activity-context';
import { type ActivityEntityType, toPropertyEntityType } from '../core/event';
import { type ActivityEntityType, toDisplayEntityType } from '../core/event';

export type EntityOpener = {
display: EntityDisplay;
Expand All @@ -28,7 +28,7 @@ export function createEntityOpener(
onOpen: ((target: OpenEntityTarget) => void) | undefined
): Accessor<EntityOpener | undefined> {
return createMemo(() => {
const type = toPropertyEntityType(entityType());
const type = toDisplayEntityType(entityType());
if (!type) return undefined;
const display = context.entityDisplay(entityId, () => type);
if (!onOpen) return { display };
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/features/activity/queries/decode.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { decodeActivityEvent } from './decode';
import {
agentSessionCreatedEvent,
callStartedEvent,
createdEvent,
deletedEvent,
Expand Down Expand Up @@ -55,6 +56,9 @@ describe('decodeActivityEvent', () => {
expect(decodeActivityEvent(createdEvent).entityType).toBe('document');
expect(decodeActivityEvent(messagedEvent).entityType).toBe('channel');
expect(decodeActivityEvent(sentEvent).entityType).toBe('email-thread');
expect(decodeActivityEvent(agentSessionCreatedEvent).entityType).toBe(
'agent-session'
);
});

it('keeps the unknown-action tag so describeAction can humanize it', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/features/activity/queries/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function decodeEntityType(
.with('EMAIL_THREAD', () => 'email-thread' as const)
.with('CHANNEL', () => 'channel' as const)
.with('USER', () => 'user' as const)
.with('AGENT_SESSION', () => 'agent-session' as const)
.otherwise((raw) => ({ kind: 'unsupported' as const, raw }));
}

Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/features/activity/queries/entity-query.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { describe, expect, it } from 'vitest';
import { soupPage } from '../tests/wire';
import { buildEntityActivityInput } from './entity-query';
import { createdEvent } from './fixtures';
import { selectEntityActivity } from './select-entity-activity';

const NIL = '00000000-0000-0000-0000-000000000000';

describe('buildEntityActivityInput', () => {
it('opts agent sessions into Soup by id', () => {
expect(
buildEntityActivityInput('AGENT_SESSION', 'session-1')
).toMatchObject({
initial: {
limit: 1,
filters: {
documentFilter: { literal: { id: NIL } },
agentSessionFilter: { literal: { id: 'session-1' } },
},
},
});
});

it('does not issue a lookup for users', () => {
expect(buildEntityActivityInput('USER', 'user-1')).toBeUndefined();
});
});

describe('selectEntityActivity', () => {
it('returns entity-missing when the soup page omits the entity', () => {
expect(selectEntityActivity(soupPage([]), 'doc-1')).toEqual({
Expand Down
39 changes: 33 additions & 6 deletions apps/web/src/features/activity/queries/entity-query.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { createUrqlQuery } from '@app/lib/urql-solid/create-urql-query';
import { buildEntityPropertiesInput } from '@queries/properties/graphql/entity';
import { buildGraphqlEntitySoupInput } from '@queries/soup/graphql/entity-input';
import type { EntityType } from '@service-properties/generated/schemas/entityType';
import {
EntityActivityDocument,
type EntityActivityQuery,
type EntityActivityQueryVariables,
type SoupInput,
} from '@service-storage/graphql/generated/graphql';
import { type Accessor, createMemo } from 'solid-js';
import type { ActivityContext } from '../context/activity-context';
Expand All @@ -16,18 +17,44 @@ import {
/** Rows requested for a side-panel activity preview. */
export const ENTITY_ACTIVITY_PREVIEW_LIMIT = 20;

const NIL_ENTITY_ID = '00000000-0000-0000-0000-000000000000';

/** Soup-backed entity kinds the entity activity query can address. */
export type EntityActivityEntityType = EntityType | 'AGENT_SESSION';

type EntityActivityQueryOptions = {
entityType: Accessor<EntityType>;
entityType: Accessor<EntityActivityEntityType>;
entityId: Accessor<string>;
enabled: Accessor<boolean>;
limit?: number;
};

/** Exact Soup input for one entity's activity edge. */
export function buildEntityActivityInput(
entityType: EntityActivityEntityType,
entityId: string
): SoupInput | undefined {
if (entityType === 'AGENT_SESSION') {
const base = buildGraphqlEntitySoupInput('DOCUMENT', NIL_ENTITY_ID);
if (!base || !('initial' in base) || !base.initial) return undefined;
return {
initial: {
...base.initial,
filters: {
...base.initial.filters,
agentSessionFilter: { literal: { id: entityId } },
},
},
};
}
return buildGraphqlEntitySoupInput(entityType, entityId);
}

/**
* Live urql query for one Soup-backed entity's recent activity, newest
* first. Reuses the exact-single-entity Soup input builder from the
* properties query, so the same entity types are supported (everything but
* `USER`) and the query pauses (`isEnabled` false) for the rest.
* first. Reuses the exact-single-entity Soup input builder so the same
* entity types are supported (everything but `USER`, plus agent sessions)
* and the query pauses (`isEnabled` false) for the rest.
*/
export function createEntityActivityQuery(
context: Pick<ActivityContext, 'graphql'>,
Expand All @@ -36,7 +63,7 @@ export function createEntityActivityQuery(
const input = createMemo(() => {
const entityId = options.entityId();
if (!options.enabled() || entityId.length === 0) return undefined;
return buildEntityPropertiesInput(options.entityType(), entityId);
return buildEntityActivityInput(options.entityType(), entityId);
});

const result = createUrqlQuery<
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/features/activity/queries/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,11 @@ export const unsupportedEntityEvent: ActivityEventFieldsFragment = {
entityId: 'team-1',
action: { __typename: 'GraphqlActivityCreated' },
};

export const agentSessionCreatedEvent: ActivityEventFieldsFragment = {
...BASE,
id: 'evt-13',
entityType: 'AGENT_SESSION',
entityId: 'session-1',
action: { __typename: 'GraphqlActivityCreated' },
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { SidePanel } from '@components/app/side-panel/SidePanel';
import CaretUpDownIcon from '@phosphor/caret-up-down.svg';
import type { EntityType } from '@service-properties/generated/schemas/entityType';
import { cn } from '@ui';
import {
createMemo,
Expand All @@ -24,14 +23,15 @@ import type { RailEnds } from '../core/feed-rows';
import { foldPanel } from '../core/fold-panel';
import { createActorName } from '../primitives/actor-name';
import { createEntityActivityState } from '../primitives/entity-activity';
import type { EntityActivityEntityType } from '../queries/entity-query';
import { useEntityActivityFlag } from '../use-entity-activity-flag';

/** Newest entries shown before the section folds behind its toggle. */
const PANEL_HEAD_LIMIT = 3;

export interface EntityActivitySectionProps {
entityId: string;
entityType: EntityType;
entityType: EntityActivityEntityType;
order?: number;
}

Expand Down
Loading
Loading