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
35 changes: 28 additions & 7 deletions apps/web/src/app/api/channels/[pageId]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
fileId?: string;
attachmentMeta?: AttachmentMeta;
};
const messageContent = typeof content === 'string' ? content : '';

// Debug: Check what content type is being received
loggers.realtime.debug('API received content type:', { type: typeof content });
Expand All @@ -105,7 +106,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
const [createdMessage] = await db.insert(channelMessages).values({
pageId: pageId,
userId: userId,
content,
content: messageContent,
fileId: fileId || null,
attachmentMeta: attachmentMeta || null,
}).returning();
Expand Down Expand Up @@ -180,11 +181,31 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
columns: { driveId: true, title: true },
with: {
drive: {
columns: { ownerId: true },
columns: { ownerId: true, name: true, slug: true },
},
},
});

if (messageContent.trim().length > 0) {
void import('@/lib/channels/agent-mention-responder')
.then(({ triggerMentionedAgentResponses }) =>
triggerMentionedAgentResponses({
userId,
channelId: pageId,
channelTitle: channel?.title || 'Channel',
channelType: 'CHANNEL',
sourceMessageId: createdMessage.id,
content: messageContent,
driveId: channel?.driveId || null,
driveName: channel?.drive?.name || null,
driveSlug: channel?.drive?.slug || null,
})
)
.catch((error) => {
loggers.realtime.error('Failed to load channel mention responder module:', error as Error);
});
}

if (channel?.driveId) {
// Get all drive members
const members = await db.query.driveMembers.findMany({
Expand All @@ -200,9 +221,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
}

// Create message preview
const messagePreview = content.length > 100
? content.substring(0, 100) + '...'
: content;
const messagePreview = messageContent.length > 100
? messageContent.substring(0, 100) + '...'
: messageContent;

// Filter to members with view permission and broadcast
// Check permissions in parallel for efficiency
Expand All @@ -225,7 +246,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
driveId: channel.driveId,
lastMessageAt: newMessage?.createdAt?.toISOString() || new Date().toISOString(),
lastMessagePreview: messagePreview,
lastMessageSender: newMessage?.user?.name || undefined,
lastMessageSender: newMessage?.aiMeta?.senderName || newMessage?.user?.name || undefined,
})
);

Expand All @@ -245,4 +266,4 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId:
}

return NextResponse.json(newMessage, { status: 201 });
}
}
14 changes: 10 additions & 4 deletions apps/web/src/app/api/inbox/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export async function GET(request: Request) {
cm."pageId",
cm.content as last_message,
cm."createdAt" as last_message_at,
u.name as sender_name
COALESCE(cm."aiMeta"->>'senderName', u.name) as sender_name
FROM channel_messages cm
INNER JOIN drive_channels dc ON dc.id = cm."pageId"
LEFT JOIN users u ON u.id = cm."userId"
Expand All @@ -69,7 +69,10 @@ export async function GET(request: Request) {
LEFT JOIN channel_read_status crs
ON crs."channelId" = cm."pageId" AND crs."userId" = ${userId}
WHERE cm."createdAt" > COALESCE(crs."lastReadAt", '1970-01-01'::timestamp)
AND cm."userId" != ${userId}
AND (
cm."userId" != ${userId}
OR cm."aiMeta"->>'senderType' = 'agent'
)
GROUP BY cm."pageId"
)
SELECT
Expand Down Expand Up @@ -217,7 +220,7 @@ export async function GET(request: Request) {
cm."pageId",
cm.content as last_message,
cm."createdAt" as last_message_at,
u.name as sender_name
COALESCE(cm."aiMeta"->>'senderName', u.name) as sender_name
FROM channel_messages cm
INNER JOIN user_channels uc ON uc.id = cm."pageId"
LEFT JOIN users u ON u.id = cm."userId"
Expand All @@ -229,7 +232,10 @@ export async function GET(request: Request) {
LEFT JOIN channel_read_status crs
ON crs."channelId" = cm."pageId" AND crs."userId" = ${userId}
WHERE cm."createdAt" > COALESCE(crs."lastReadAt", '1970-01-01'::timestamp)
AND cm."userId" != ${userId}
AND (
cm."userId" != ${userId}
OR cm."aiMeta"->>'senderType' = 'agent'
)
GROUP BY cm."pageId"
)
SELECT
Expand Down
35 changes: 30 additions & 5 deletions apps/web/src/app/dashboard/inbox/channel/[pageId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ interface MessageWithUser {
fileId?: string | null;
attachmentMeta?: AttachmentMeta | null;
file?: FileRelation | null;
aiMeta?: {
senderType: 'global_assistant' | 'agent';
senderName: string;
agentPageId?: string;
} | null;
}

interface Page {
Expand Down Expand Up @@ -394,15 +399,34 @@ export default function InboxChannelPage() {
<PullToRefresh direction="top" onRefresh={handleRefresh}>
<ScrollArea className="h-full flex-grow" ref={scrollAreaRef}>
<div className="p-4 space-y-4 max-w-4xl mx-auto">
{messages.map((m) => (
{messages.map((m) => {
const isAi = !!m.aiMeta;
const displayName = isAi ? m.aiMeta!.senderName : m.user?.name;
const aiLabel = isAi
? m.aiMeta!.senderType === 'global_assistant'
? 'global assistant'
: 'agent'
: null;
const avatarFallback = isAi
? m.aiMeta!.senderType === 'agent'
? 'A'
: m.aiMeta!.senderName?.[0]
: m.user?.name?.[0];

return (
<div key={m.id} className="group flex items-start gap-4">
<Avatar className="shrink-0">
<AvatarImage src={m.user?.image || ''} />
<AvatarFallback>{m.user?.name?.[0]}</AvatarFallback>
{!isAi && <AvatarImage src={m.user?.image || ''} />}
<AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{m.user?.name}</span>
<span className="font-semibold text-sm">{displayName}</span>
{aiLabel && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 font-medium">
{aiLabel}
</span>
)}
<span className="text-xs text-muted-foreground">
{new Date(m.createdAt).toLocaleTimeString()}
</span>
Expand Down Expand Up @@ -471,7 +495,8 @@ export default function InboxChannelPage() {
)}
</div>
</div>
))}
);
})}
</div>
</ScrollArea>
</PullToRefresh>
Expand Down
116 changes: 110 additions & 6 deletions apps/web/src/lib/ai/tools/__tests__/channel-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';

/**
* Channel Tools Tests
Expand All @@ -14,6 +14,7 @@ vi.mock('@pagespace/db', () => ({
query: {
channelMessages: { findFirst: vi.fn() },
pages: { findFirst: vi.fn() },
driveMembers: { findMany: vi.fn() },
},
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
Expand All @@ -24,6 +25,7 @@ vi.mock('@pagespace/db', () => ({
},
channelMessages: {},
channelReadStatus: { userId: 'userId', channelId: 'channelId' },
driveMembers: { driveId: 'driveId' },
pages: { id: 'id', isTrashed: 'isTrashed' },
eq: vi.fn(),
and: vi.fn(),
Expand Down Expand Up @@ -71,15 +73,20 @@ vi.mock('@/lib/logging/mask', () => ({
}));

import { channelTools } from '../channel-tools';
import { canUserEditPage } from '@pagespace/lib/permissions';
import { canUserEditPage, canUserViewPage } from '@pagespace/lib/permissions';
import { getActorInfo } from '@pagespace/lib/server';
import { db } from '@pagespace/db';
import { broadcastInboxEvent } from '@/lib/websocket/socket-utils';
import type { ToolExecutionContext } from '../../core';

const mockCanUserEditPage = vi.mocked(canUserEditPage);
const mockCanUserViewPage = vi.mocked(canUserViewPage);
const mockGetActorInfo = vi.mocked(getActorInfo);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockPagesFindFirst = db.query.pages.findFirst as any;
const mockBroadcastInboxEvent = vi.mocked(broadcastInboxEvent);
const mockDbInsert = db.insert as unknown as Mock;
const mockPagesFindFirst = db.query.pages.findFirst as unknown as Mock;
const mockChannelMessagesFindFirst = db.query.channelMessages.findFirst as unknown as Mock;
const mockDriveMembersFindMany = db.query.driveMembers.findMany as unknown as Mock;

// Helper to safely extract result from tool execution (handles AsyncIterable union)
type ToolResult = Record<string, unknown>;
Expand All @@ -92,6 +99,20 @@ const executeToolAs = async (
describe('channel-tools', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetActorInfo.mockResolvedValue({
actorEmail: 'test@example.com',
actorDisplayName: 'Test User',
});
mockCanUserViewPage.mockResolvedValue(true);
mockChannelMessagesFindFirst.mockResolvedValue({
id: 'msg-1',
createdAt: new Date('2026-02-10T12:00:00.000Z'),
user: { id: 'user-123', name: 'Alice', image: null },
file: null,
reactions: [],
});
mockDriveMembersFindMany.mockResolvedValue([]);
mockBroadcastInboxEvent.mockResolvedValue(undefined);
});

describe('send_channel_message', () => {
Expand Down Expand Up @@ -226,7 +247,7 @@ describe('channel-tools', () => {
expect(result.messagePreview).toBe('Hello from assistant');
});

it('sends message as page agent with agent title', async () => {
it('sends message as page agent with agent + user display name', async () => {
mockPagesFindFirst.mockResolvedValue({
id: 'ch-1',
title: 'General',
Expand Down Expand Up @@ -260,10 +281,93 @@ describe('channel-tools', () => {
);

expect(result.success).toBe(true);
expect(result.senderName).toBe('Budget Analyst');
expect(result.senderName).toBe('Budget Analyst (Test User)');
expect(result.senderType).toBe('agent');
});

it('marks global assistant messages as read and skips sender inbox broadcast', async () => {
mockPagesFindFirst.mockResolvedValue({
id: 'ch-1',
title: 'General',
type: 'CHANNEL',
driveId: 'drive-1',
drive: { ownerId: 'user-456' },
});
mockDriveMembersFindMany.mockResolvedValue([
{ userId: 'user-123' },
{ userId: 'user-456' },
]);
mockCanUserEditPage.mockResolvedValue(true);
mockGetActorInfo.mockResolvedValue({
actorEmail: 'alice@example.com',
actorDisplayName: 'Alice',
});

const context = {
toolCallId: '1', messages: [],
experimental_context: {
userId: 'user-123',
chatSource: { type: 'global' },
} as ToolExecutionContext,
};

const result = await executeToolAs(
{ channelId: 'ch-1', content: 'Global update' },
context
);

expect(result.success).toBe(true);
expect(mockDbInsert).toHaveBeenCalledTimes(2);
expect(mockBroadcastInboxEvent).toHaveBeenCalledTimes(1);
expect(mockBroadcastInboxEvent).toHaveBeenCalledWith(
'user-456',
expect.objectContaining({
operation: 'channel_updated',
id: 'ch-1',
})
);
});

it('keeps agent messages unread for requester and includes requester in inbox broadcast', async () => {
mockPagesFindFirst.mockResolvedValue({
id: 'ch-1',
title: 'General',
type: 'CHANNEL',
driveId: 'drive-1',
drive: { ownerId: 'user-456' },
});
mockDriveMembersFindMany.mockResolvedValue([
{ userId: 'user-123' },
{ userId: 'user-456' },
]);
mockCanUserEditPage.mockResolvedValue(true);

const context = {
toolCallId: '1', messages: [],
experimental_context: {
userId: 'user-123',
chatSource: {
type: 'page',
agentPageId: 'agent-1',
agentTitle: 'Budget Analyst',
},
} as ToolExecutionContext,
};

const result = await executeToolAs(
{ channelId: 'ch-1', content: 'Agent follow-up' },
context
);

expect(result.success).toBe(true);
expect(mockDbInsert).toHaveBeenCalledTimes(1);
expect(mockBroadcastInboxEvent).toHaveBeenCalledTimes(2);

const recipients = mockBroadcastInboxEvent.mock.calls.map(([recipient]) => recipient);
expect(recipients).toContain('user-123');
expect(recipients).toContain('user-456');
});

it('defaults to global_assistant when chatSource is not provided', async () => {
mockPagesFindFirst.mockResolvedValue({
id: 'ch-1',
Expand Down
Loading