Skip to content
Merged
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 @@ -12,10 +12,12 @@
import { DiffCounts } from '@app/features/agent-changes/components/DiffCounts';
import { useOptionalAgentChanges } from '@app/features/agent-changes/context/agent-changes-controller';
import { SidePanel } from '@components/app/side-panel';
import { References } from '@core/component/References';
import { formatDate } from '@core/util/date';
import { openExternalUrl } from '@core/util/url';
import GitBranch from '@phosphor/git-branch.svg';
import { createMemo, For, Show } from 'solid-js';
import { useAttachmentReferencesQuery } from '@queries/storage/attachment-references';
import { createMemo, For, Show, Suspense } from 'solid-js';
import { useAgentSession } from '../../context/AgentSessionContext';
import { sessionStatus } from '../../state/session-status';
import { activityCounts, latestPlan } from '../../state/session-summary';
Expand All @@ -28,7 +30,7 @@ import {
} from '../compose-agent-session-options';

export function AgentSidePanelSections() {
const { session, bot, metadata, messages } = useAgentSession();
const { sessionId, session, bot, metadata, messages } = useAgentSession();

const plan = createMemo(() => latestPlan(messages()));
const changes = useOptionalAgentChanges();
Expand Down Expand Up @@ -174,10 +176,46 @@ export function AgentSidePanelSections() {
</div>
</SidePanel.Section>
</Show>

<ReferencesSectionConditional sessionId={sessionId()} />
</>
);
}

/**
* Where this session is referenced: channel messages that mention or attach
* it, and documents that mention it. Same section the markdown, email, and
* call blocks show; hidden until at least one reference exists.
*/
function ReferencesSectionConditional(props: { sessionId?: string }) {
const references = useAttachmentReferencesQuery(
() => props.sessionId,
() => 'agent_session'
);

// Gate the resource read on status so a pending query never suspends the
// enclosing block while the section is hidden anyway.
const count = () => (references.isSuccess ? references.data.length : 0);

return (
<Show when={count() > 0 ? props.sessionId : undefined}>
{(sessionId) => (
<SidePanel.Section
id="references"
title={<SidePanel.CountTitle label="References" count={count()} />}
order={40}
>
<Suspense fallback={<SidePanel.Loading />}>
<div class="text-xs">
<References documentId={sessionId()} entityType="agent_session" />
</div>
</Suspense>
</SidePanel.Section>
)}
</Show>
);
}

/** `https://github.com/org/repo.git` → `org/repo` for a compact pill. */
function repoName(url: string): string {
const path = url
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/features/block-md/component/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -404,10 +404,11 @@ export function MarkdownEditor(props: {
const dragInsertPosition = getValidDragInsertPosition(editor, res.mousePos);
if (!dragInsertPosition) return;

const mentionId =
res.item.type === 'agent_session'
? undefined
: await trackMention(blockId, 'document', res.id);
const mentionId = await trackMention(
blockId,
res.item.type === 'agent_session' ? 'agent_session' : 'document',
res.id
);

let blockParams: Record<string, string> | undefined;
if (res.blockName === 'channel') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { LexicalEditor } from 'lexical';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@core/signal/mention', () => ({ trackMention: vi.fn() }));
const { trackMention } = vi.hoisted(() => ({ trackMention: vi.fn() }));
vi.mock('@core/signal/mention', () => ({ trackMention }));
vi.mock('./entityUtils', () => ({ getBlockNameFromEntity: vi.fn(() => 'md') }));
vi.mock('../../../../plugins', () => ({
REMOVE_INLINE_SEARCH_COMMAND: 'remove-search',
Expand Down Expand Up @@ -41,6 +42,10 @@ const item: AgentSessionMentionItem = {
};

describe('agent session menu selection', () => {
beforeEach(() => {
trackMention.mockReset();
});

it('inserts its own node without invoking user/document attachment callbacks', async () => {
const dispatchCommand = vi.fn();
const onDocumentMention = vi.fn();
Expand All @@ -62,6 +67,45 @@ describe('agent session menu selection', () => {
});
expect(onDocumentMention).not.toHaveBeenCalled();
expect(onUserMention).not.toHaveBeenCalled();
expect(trackMention).not.toHaveBeenCalled();
});

it('records a document reference so the session lists the doc under References', async () => {
trackMention.mockResolvedValue('mention-uuid');
const dispatchCommand = vi.fn();
const handler = createItemHandler({
editor: { dispatchCommand } as unknown as LexicalEditor,
blockId: 'doc-1',
blockName: 'write',
});
await handler(item);
expect(trackMention).toHaveBeenCalledWith(
'doc-1',
'agent_session',
'session'
);
expect(dispatchCommand).toHaveBeenNthCalledWith(2, 'insert-session', {
id: 'session',
label: 'Fix mentions',
mentionUuid: 'mention-uuid',
});
});

it('does not track when the host is a channel or chat composer', async () => {
for (const blockName of ['channel', 'chat'] as const) {
const dispatchCommand = vi.fn();
const handler = createItemHandler({
editor: { dispatchCommand } as unknown as LexicalEditor,
blockId: 'host-1',
blockName,
});
await handler(item);
expect(trackMention).not.toHaveBeenCalled();
expect(dispatchCommand).toHaveBeenNthCalledWith(2, 'insert-session', {
id: 'session',
label: 'Fix mentions',
});
}
});
it('participates in the mobile search list', () => {
expect(sortMobileMentions([item], 'Ada')).toEqual([item]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,41 +32,57 @@ function entityDisplayName(item: EntityItem): string {
return '';
}

/** Whether a mention inserted into this editor is recorded as a document reference. */
function tracksMentions(dependencies: HandlerDependencies): boolean {
const { blockId, blockName, disableMentionTracking } = dependencies;
return Boolean(
blockId &&
blockName !== 'channel' &&
blockName !== 'chat' &&
!disableMentionTracking
);
}

/**
* Insert an agent session chip. It is a reference, not a bot invocation, so
* it skips the document/user attachment callbacks — but it is tracked like
* any other entity mention so the session's References panel lists the doc.
*/
async function handleAgentSessionMention(
session: { id: string; name?: string },
dependencies: HandlerDependencies
): Promise<void> {
const { editor, blockId } = dependencies;
const mentionUuid =
blockId && tracksMentions(dependencies)
? await trackMention(blockId, 'agent_session', session.id)
: undefined;
editor.dispatchCommand(INSERT_AGENT_SESSION_MENTION_COMMAND, {
id: session.id,
label: session.name,
...(mentionUuid ? { mentionUuid } : {}),
});
}

/**
* Handle entity mention (documents, channels, emails, etc.).
*/
async function handleEntityMention(
item: EntityItem,
dependencies: HandlerDependencies
): Promise<void> {
const {
editor,
blockName,
blockId,
onDocumentMention,
disableMentionTracking,
onEmailMention,
} = dependencies;
const { editor, blockId, onDocumentMention, onEmailMention } = dependencies;

const entity = item.data;
if (entity.type === 'agent_session') {
editor.dispatchCommand(INSERT_AGENT_SESSION_MENTION_COMMAND, {
id: entity.id,
label: entity.name,
});
return;
return await handleAgentSessionMention(entity, dependencies);
}

const blockNameForMention = getBlockNameFromEntity(item);
const itemName = entityDisplayName(item);

let mentionId: string | undefined;
if (
blockId &&
blockName !== 'channel' &&
blockName !== 'chat' &&
!disableMentionTracking
) {
if (blockId && tracksMentions(dependencies)) {
const trackType =
item.bucket === 'channel' || item.bucket === 'dm'
? 'channel'
Expand Down Expand Up @@ -137,11 +153,10 @@ export function createItemHandler(dependencies: HandlerDependencies) {
case 'group':
return await handleGroupMentionItem(item.data, dependencies);
case 'agentSession':
dependencies.editor.dispatchCommand(
INSERT_AGENT_SESSION_MENTION_COMMAND,
{ id: item.id, label: item.data.name }
return await handleAgentSessionMention(
{ id: item.id, name: item.data.name },
dependencies
);
return;
case 'entity':
return await handleEntityMention(item, dependencies);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -579,8 +579,13 @@ function registerMentionsPlugin(
if (!$isAgentSessionMentionNode(node)) continue;
if (mutation === 'created')
onCreateMention?.($mentionItemFromNode(node));
if (mutation === 'destroyed')
if (mutation === 'destroyed') {
const mentionUuid = node.getMentionUuid();
if (mentionUuid && sourceDocumentId) {
untrackMention(sourceDocumentId, mentionUuid);
}
onRemoveMention?.($mentionItemFromNode(node));
}
}
updateMentionsSignal();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ vi.hoisted(() => {
vi.mock('@core/constant/allBlocks', () => ({
verifyBlockName: (name: string) => name,
}));
vi.mock('@core/signal/mention', () => ({
untrackMention: vi.fn(),
}));
const { untrackMention } = vi.hoisted(() => ({ untrackMention: vi.fn() }));
vi.mock('@core/signal/mention', () => ({ untrackMention }));
vi.mock('@service-storage/client', () => ({
blockNameToItemType: (name: string) => {
const map: Record<string, string> = {
Expand Down Expand Up @@ -70,6 +69,7 @@ import {
type LexicalEditor,
} from 'lexical';
import {
INSERT_AGENT_SESSION_MENTION_COMMAND,
INSERT_CONTACT_MENTION_COMMAND,
INSERT_DATE_MENTION_COMMAND,
INSERT_DOCUMENT_MENTION_COMMAND,
Expand Down Expand Up @@ -357,6 +357,51 @@ describe('mentionsPlugin callbacks', () => {
cleanup();
});

test('removing an agent session mention untracks its document reference', () => {
untrackMention.mockClear();
const editor = createTestEditor();
const created: ItemMention[] = [];
const removed: ItemMention[] = [];
const cleanup = mentionsPlugin({
sourceDocumentId: 'doc-1',
onCreateMention: (mention) => created.push(mention),
onRemoveMention: (mention) => removed.push(mention),
})(editor);

editor.dispatchCommand(INSERT_AGENT_SESSION_MENTION_COMMAND, {
id: 'session-1',
label: 'Fix mentions',
mentionUuid: 'uuid-session',
});
editor.read(() => {});

expect(created).toContainEqual(
expect.objectContaining({
itemType: 'agent_session',
itemId: 'session-1',
})
);
expect(untrackMention).not.toHaveBeenCalled();

editor.update(
() => {
$getRoot().clear().append($createParagraphNode());
},
{ discrete: true }
);
editor.read(() => {});

expect(removed).toContainEqual(
expect.objectContaining({
itemType: 'agent_session',
itemId: 'session-1',
})
);
expect(untrackMention).toHaveBeenCalledWith('doc-1', 'uuid-session');

cleanup();
});

test('custom plugin passed to builder runs and cleans up', () => {
const editor = createTestEditor();
const pluginInit = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export function insertDocumentMentionAtDragInsertPosition(
? $createAgentSessionMentionNode({
id: mentionInfo.documentId,
label: mentionInfo.documentName,
mentionUuid: mentionInfo.mentionUuid,
})
: $createDocumentMentionNode({
...mentionInfo,
Expand Down
7 changes: 6 additions & 1 deletion docs/AGENT_GUIDE/ai-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,15 @@ In the Agents workspace, saved sessions use the shared top-bar controls: session
icon, title and action menu, Share, Copy Share Link, and a side-panel toggle.
There is no breadcrumb because Agents has no subspaces. Unknown model providers
fall back to the chat icon. The toggle (or `]`) opens the session's Details, Plan,
Changes, and Activity sections when available, beside the transcript in wide
Changes, Activity, and References sections when available, beside the transcript in wide
layouts or over it in narrow layouts; it does not open another split.
Details lists Status, Agent, Model, and dates for every session; the Harness
row appears only for coding runtimes, never for in-memory chat agents.
`References` is the same section documents show: one row per channel message that
`@`-mentioned or shared the session (sender, channel chip, time, and a two-line
message excerpt) and per document that mentions it (author and document chip).
Click a row to open that message or document in a split. The section is hidden
until at least one reference exists, and only lists channels you belong to.
New conversation pages have no disabled session action buttons. Older chats
also have one header row, and empty chats show a simple conversation prompt
instead of the standalone recent-sessions and tips surface.
Expand Down
Loading