Skip to content
Merged
1,599 changes: 1,533 additions & 66 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions packages/observer-dashboard/src/components/AgentSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from 'react';
import { Hash, MessageSquare, LogOut, Sun, Moon } from 'lucide-react';
import { cn } from '../lib/utils';
import { cn, formatDmLabel } from '../lib/utils';
import { AgentAvatar } from './AgentAvatar';
import { clearAuth } from '../lib/auth';
import { useRouter } from 'next/navigation';
Expand All @@ -16,6 +16,7 @@ interface AgentSidebarProps {
conversations: DmConversationSummary[];
selectedChannel: string | null;
selectedAgent: string | null;
unreadChannelCounts: Record<string, number>;
wsStatus: ConnectionStatus;
onSelectChannel: (name: string | null) => void;
onSelectAgent: (name: string | null) => void;
Expand Down Expand Up @@ -43,6 +44,7 @@ export function AgentSidebar({
conversations,
selectedChannel,
selectedAgent,
unreadChannelCounts,
wsStatus,
onSelectChannel,
onSelectAgent,
Expand Down Expand Up @@ -109,9 +111,9 @@ export function AgentSidebar({
>
<Hash className="h-3.5 w-3.5 shrink-0 opacity-60" />
<span className="truncate flex-1 text-left">{ch.name}</span>
{(ch.memberCount ?? 0) > 0 && (
<span className="text-[10px] text-[var(--color-text-dim)] bg-[var(--color-bg-hover)] px-1.5 py-0.5 rounded-full shrink-0">
{ch.memberCount}
{(unreadChannelCounts[ch.name] ?? 0) > 0 && (
<span className="text-[10px] font-semibold text-white bg-red-500 px-1.5 py-0.5 rounded-full shrink-0">
{unreadChannelCounts[ch.name]}
</span>
)}
</button>
Expand All @@ -131,7 +133,7 @@ export function AgentSidebar({
</h2>
{conversations.map((dm) => {
const dmKey = `dm:${dm.id}`;
const dmLabel = dm.name || dm.participants.map((p) => p.agentName).join(', ');
const dmLabel = formatDmLabel(dm.participants, dm.name);
return (
<button
key={dm.id}
Expand Down
20 changes: 17 additions & 3 deletions packages/observer-dashboard/src/components/ChatFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { Hash, MessageSquare } from 'lucide-react';
import { Hash, MessageSquare, UserRound } from 'lucide-react';
import { useMessages, sortMessagesChronologically } from '@relaycast/react';
import { MessageCard } from './MessageCard';
import type { MessageWithMeta } from '@relaycast/sdk';

interface ChatFeedProps {
selectedChannel: string | null;
selectedChannelMemberCount?: number | null;
dmLabel?: string;
onOpenThread?: (messageId: string) => void;
}

export function ChatFeed({ selectedChannel, dmLabel, onOpenThread }: ChatFeedProps) {
export function ChatFeed({
selectedChannel,
selectedChannelMemberCount,
dmLabel,
onOpenThread,
}: ChatFeedProps) {
const isDm = selectedChannel?.startsWith('dm:');
const channelName = selectedChannel && !isDm ? selectedChannel : null;
const dmId = isDm ? selectedChannel!.slice(3) : null;
Expand All @@ -22,6 +28,8 @@ export function ChatFeed({ selectedChannel, dmLabel, onOpenThread }: ChatFeedPro
? dmLabel || 'Direct Message'
: `#${selectedChannel}`
: 'Select a channel';
const memberCount = selectedChannelMemberCount ?? 0;
const showMemberBadge = !!channelName;

return (
<div className="flex-1 flex flex-col min-w-0">
Expand All @@ -32,7 +40,13 @@ export function ChatFeed({ selectedChannel, dmLabel, onOpenThread }: ChatFeedPro
) : (
<MessageSquare className="h-4 w-4 text-[var(--color-text-muted)]" />
)}
<h2 className="font-semibold text-sm text-[var(--color-text-primary)]">{title}</h2>
<h2 className="font-semibold text-sm text-[var(--color-text-primary)] flex-1">{title}</h2>
{showMemberBadge && (
<span className="inline-flex items-center gap-1.5 rounded-2xl border border-[var(--color-border-default)] px-2.5 py-1 text-sm text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] shrink-0">
<UserRound className="h-3.5 w-3.5 text-[var(--color-text-muted)]" />
<span>{memberCount}</span>
</span>
)}
</div>

{/* Messages */}
Expand Down
73 changes: 64 additions & 9 deletions packages/observer-dashboard/src/components/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import { useCallback, useEffect, useState } from 'react';
import { Activity, AlertTriangle } from 'lucide-react';
import { usePresence, useChannels, useWebSocket } from '@relaycast/react';
import { useEvent, usePresence, useChannels, useWebSocket } from '@relaycast/react';
import { AgentSidebar } from './AgentSidebar';
import { ChatFeed } from './ChatFeed';
import { ActivityLog } from './ActivityLog';
import { ThreadPanel } from './ThreadPanel';
import { AgentPanel } from './AgentPanel';
import { cn } from '../lib/utils';
import { cn, formatDmLabel } from '../lib/utils';
import { useWorkspaceDMs } from '../hooks/use-workspace-dms';
import type { Agent as ApiAgent } from '@relaycast/sdk';
import type { Agent as ApiAgent, MessageCreatedEvent } from '@relaycast/sdk';

export function DashboardLayout() {
const { agents: rawAgents } = usePresence();
Expand All @@ -19,6 +19,7 @@ export function DashboardLayout() {
const { status: wsStatus } = useWebSocket();
const [selectedChannel, setSelectedChannel] = useState<string | null>(null);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
const [unreadChannelCounts, setUnreadChannelCounts] = useState<Record<string, number>>({});
const [activityOpen, setActivityOpen] = useState(true);
const [streamEnabled, setStreamEnabled] = useState<boolean | null>(null);
const [streamMessage, setStreamMessage] = useState<string>('');
Expand All @@ -27,11 +28,48 @@ export function DashboardLayout() {
// Default to first channel if none selected
useEffect(() => {
if (!selectedChannel && channels.length > 0) {
setSelectedChannel(channels[0].name);
const firstChannel = channels[0].name;
setSelectedChannel(firstChannel);
setUnreadChannelCounts((prev) =>
prev[firstChannel] && prev[firstChannel] > 0
? { ...prev, [firstChannel]: 0 }
: prev,
);
}
}, [selectedChannel, channels]);
const [threadMessageId, setThreadMessageId] = useState<string | null>(null);

useEffect(() => {
const knownChannels = new Set(channels.map((ch) => ch.name));
setUnreadChannelCounts((prev) => {
let changed = false;
const next: Record<string, number> = {};
for (const [name, count] of Object.entries(prev)) {
if (knownChannels.has(name)) {
next[name] = count;
} else {
changed = true;
}
}
return changed ? next : prev;
});
}, [channels]);

useEvent('message.created', (evt) => {
const event = evt as MessageCreatedEvent;
const channelName = event.channel;
if (!channelName) return;

if (selectedChannel === channelName) {
return;
}

setUnreadChannelCounts((prev) => ({
...prev,
[channelName]: (prev[channelName] ?? 0) + 1,
}));
});

// Filter out the dashboard observer agent and stale offline agents (offline > 5 min)
const STALE_OFFLINE_MS = 5 * 60 * 1000;
const agents = rawAgents.filter((a) => {
Expand Down Expand Up @@ -107,12 +145,31 @@ export function DashboardLayout() {
setSelectedChannel(name);
setSelectedAgent(null);
setThreadMessageId(null);
if (name && !name.startsWith('dm:')) {
setUnreadChannelCounts((prev) =>
prev[name] && prev[name] > 0
? { ...prev, [name]: 0 }
: prev,
);
}
}

// Determine right panel priority: agent panel > thread > activity
const selectedAgentData: ApiAgent | null = selectedAgent
? agents.find((a) => a.name === selectedAgent) ?? null
: null;
const selectedChannelMemberCount =
selectedChannel && !selectedChannel.startsWith('dm:')
? (channels.find((ch) => ch.name === selectedChannel)?.memberCount ?? 0)
: null;
const selectedDmLabel =
selectedChannel?.startsWith('dm:')
? (() => {
const conversation = conversations.find((dm) => `dm:${dm.id}` === selectedChannel);
if (!conversation) return undefined;
return formatDmLabel(conversation.participants, conversation.name);
})()
: undefined;
Comment thread
willwashburn marked this conversation as resolved.

let rightPanel: React.ReactNode = null;
if (selectedAgentData) {
Expand Down Expand Up @@ -143,6 +200,7 @@ export function DashboardLayout() {
conversations={conversations}
selectedChannel={selectedChannel}
selectedAgent={selectedAgent}
unreadChannelCounts={unreadChannelCounts}
wsStatus={wsStatus}
onSelectChannel={handleSelectChannel}
onSelectAgent={handleSelectAgent}
Expand Down Expand Up @@ -192,11 +250,8 @@ export function DashboardLayout() {
<div className="flex flex-1 min-h-0">
<ChatFeed
selectedChannel={selectedChannel}
dmLabel={
selectedChannel?.startsWith('dm:')
? conversations.find((dm) => `dm:${dm.id}` === selectedChannel)?.name ?? undefined
: undefined
}
selectedChannelMemberCount={selectedChannelMemberCount}
dmLabel={selectedDmLabel}
onOpenThread={(id) => setThreadMessageId(id)}
/>
{rightPanel}
Expand Down
10 changes: 6 additions & 4 deletions packages/observer-dashboard/src/components/MessageCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { formatReplyCountLabel } from '@relaycast/react';
import { formatReplyCountLabel, MessageMarkdown } from '@relaycast/react';
import { AgentAvatar } from './AgentAvatar';
import type { MessageWithMeta } from '@relaycast/sdk';

Expand Down Expand Up @@ -41,9 +41,11 @@ export function MessageCard({ message, compact = false, onOpenThread }: MessageC
</span>
</div>
)}
<p className="text-sm text-[var(--color-text-secondary)] break-words whitespace-pre-wrap">
{message.text}
</p>
<MessageMarkdown
text={message.text}
className="text-sm text-[var(--color-text-secondary)] break-words"
showCodeCopyButton
/>
{((message.reactions?.length ?? 0) > 0 || message.replyCount > 0) && (
<div className="flex items-center gap-2 mt-1">
{(message.reactions ?? []).map((r) => (
Expand Down
10 changes: 6 additions & 4 deletions packages/observer-dashboard/src/components/ThreadPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { X, MessageSquare } from 'lucide-react';
import { useThread } from '@relaycast/react';
import { MessageMarkdown, useThread } from '@relaycast/react';
import { AgentAvatar } from './AgentAvatar';
import type { MessageWithMeta } from '@relaycast/sdk';

Expand Down Expand Up @@ -30,9 +30,11 @@ function ThreadMessageRow({ msg }: { msg: MessageWithMeta }) {
{relativeTime(msg.createdAt)}
</span>
</div>
<p className="text-sm text-[var(--color-text-secondary)] break-words whitespace-pre-wrap">
{msg.text}
</p>
<MessageMarkdown
text={msg.text}
className="text-sm text-[var(--color-text-secondary)] break-words"
showCodeCopyButton
/>
</div>
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions packages/observer-dashboard/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,21 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export interface DmParticipantLike {
agentName: string;
}

export function formatDmLabel(
participants: DmParticipantLike[],
fallbackName?: string | null,
): string {
const participantLabel = participants
.map((participant) => participant.agentName.trim())
.filter((name) => name.length > 0)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
.join(', ');

const normalizedFallback = fallbackName?.trim();
return participantLabel || normalizedFallback || 'Direct Message';
}
6 changes: 5 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@
},
"dependencies": {
"@relaycast/sdk": "0.4.2",
"@relaycast/types": "0.4.2"
"@relaycast/types": "0.4.2",
"prism-react-renderer": "^2.4.1",
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
Expand Down
74 changes: 74 additions & 0 deletions packages/react/src/__tests__/messageMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MessageMarkdown } from '../components/MessageMarkdown.js';

describe('MessageMarkdown', () => {
it('renders markdown with soft line breaks and list items', () => {
const { container } = render(
<MessageMarkdown text={'**bold** line\nsecond line\n\n- one\n- two'} />,
);

const strong = container.querySelector('strong');
expect(strong?.textContent).toBe('bold');
expect(container.querySelectorAll('br')).toHaveLength(1);
expect(Array.from(container.querySelectorAll('li')).map((node) => node.textContent)).toEqual([
'one',
'two',
]);
});

it('applies secure attributes to external links', () => {
const { container } = render(
<MessageMarkdown text={'[docs](https://example.com) and [local](/general)'} />,
);

const links = Array.from(container.querySelectorAll('a'));
expect(links[0]?.getAttribute('href')).toBe('https://example.com');
expect(links[0]?.getAttribute('target')).toBe('_blank');
expect(links[0]?.getAttribute('rel')).toBe('noopener noreferrer nofollow');
expect(links[1]?.getAttribute('href')).toBe('/general');
expect(links[1]?.getAttribute('target')).toBeNull();
Comment thread
willwashburn marked this conversation as resolved.
expect(links[1]?.getAttribute('rel')).toBeNull();
});

it('renders highlighted fenced code and supports copy button', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});

const { container, getByRole } = render(
<MessageMarkdown
text={'```ts\nconst answer: number = 42;\n```'}
showCodeCopyButton
/>,
);

const code = container.querySelector('pre code');
expect(code).not.toBeNull();
expect(code?.querySelectorAll('span').length ?? 0).toBeGreaterThan(2);

const copyButton = getByRole('button', { name: 'Copy code' });
fireEvent.click(copyButton);

await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('const answer: number = 42;');
expect(copyButton.textContent).toBe('Copied');
});
});

it('preserves GFM table column alignment styles', () => {
const { container } = render(
<MessageMarkdown text={'| left | right |\n| :--- | ---: |\n| a | b |'} />,
);

const headerCells = container.querySelectorAll('th');
expect(headerCells[0]?.getAttribute('style')).toContain('text-align: left');
expect(headerCells[1]?.getAttribute('style')).toContain('text-align: right');

const rowCells = container.querySelectorAll('td');
expect(rowCells[0]?.getAttribute('style')).toContain('text-align: left');
expect(rowCells[1]?.getAttribute('style')).toContain('text-align: right');
});
});
Loading
Loading