Relay Conversation View Implementation Plan
Relay Conversation View Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Transform the Relay panel from a raw message log into a human-readable conversation view with bug fixes for payload display and trace loading.
Architecture: Server-side conversation grouping endpoint correlates request/response messages, resolves agent names from manifests, and includes payloads from Maildir. Client swaps MessageRow for ConversationRow with progressive disclosure. Trace store is wired into RelayCore.publish().
Tech Stack: Express routes, RelayCore (packages/relay), Zod schemas, React 19, TanStack Query, motion/react, Tailwind 4, shadcn/ui
Design Doc: docs/plans/2026-02-27-relay-conversation-view-design.md
Task 1: Wire TraceStore into RelayCore.publish()
Files:
- Modify:
packages/relay/src/types.ts:175-195(add TraceStoreLike to RelayCore deps) - Modify:
packages/relay/src/relay-core.ts:247-350(wire insertSpan after delivery) - Test:
packages/relay/src/__tests__/relay-core.test.ts
Context:
- TraceStore.insertSpan signature (from
apps/server/src/services/relay/trace-store.ts:68-98): accepts{ messageId, traceId, subject, status?, metadata? } - RelayCore already has optional deps pattern (adapterRegistry, opts)
- The trace route (
apps/server/src/routes/relay.ts:137-147) looks up spans by messageId, so traceId should equal messageId for single-message traces
Step 1: Add TraceStoreLike interface to types.ts
In packages/relay/src/types.ts, add after the AdapterRegistryLike interface (~line 213):
/** Minimal trace store contract for RelayCore to record delivery spans. */
export interface TraceStoreLike {
insertSpan(span: {
messageId: string;
traceId: string;
subject: string;
status?: string;
metadata?: Record<string, unknown>;
}): void;
}Step 2: Add traceStore to RelayCore constructor options
In packages/relay/src/relay-core.ts, add traceStore?: TraceStoreLike to the RelayCoreOptions interface and store it as a private field.
Step 3: Wire insertSpan into publish()
After the return statement assembly at line ~343 (before the return { block), add:
// 9. Record trace span for delivery tracking
if (this.traceStore) {
try {
this.traceStore.insertSpan({
messageId,
traceId: messageId,
subject,
status: deliveredTo > 0 ? 'delivered' : 'failed',
metadata: {
deliveredTo,
rejectedCount: rejected.length,
hasAdapterResult: !!adapterResult,
durationMs: Date.now() - new Date(envelope.createdAt).getTime(),
},
});
} catch {
// Trace insertion is best-effort — never fail a publish for tracing
}
}Step 4: Write test
it('records trace span when traceStore is provided', async () => {
const mockTraceStore = { insertSpan: vi.fn() };
const core = createRelayCore({ traceStore: mockTraceStore });
// register an endpoint so delivery succeeds
await core.registerEndpoint('relay.test.subject');
await core.publish('relay.test.subject', { content: 'hello' }, {
from: 'relay.test.sender',
replyTo: 'relay.test.sender',
});
expect(mockTraceStore.insertSpan).toHaveBeenCalledWith(
expect.objectContaining({
subject: 'relay.test.subject',
status: 'delivered',
}),
);
});
it('records failed trace span for dead-lettered messages', async () => {
const mockTraceStore = { insertSpan: vi.fn() };
const core = createRelayCore({ traceStore: mockTraceStore });
await core.publish('relay.nowhere.subject', { content: 'lost' }, {
from: 'relay.test.sender',
replyTo: 'relay.test.sender',
});
expect(mockTraceStore.insertSpan).toHaveBeenCalledWith(
expect.objectContaining({
status: 'failed',
}),
);
});Step 5: Run tests
Run: pnpm --filter @dorkos/relay exec vitest run
Expected: All pass including new trace tests
Step 6: Wire TraceStore in server startup
In apps/server/src/index.ts (or wherever RelayCore is constructed), pass the existing traceStore instance to RelayCore's options. Find where new RelayCore(...) is called and add traceStore to the options object.
Step 7: Commit
git add packages/relay/src/types.ts packages/relay/src/relay-core.ts packages/relay/src/__tests__/relay-core.test.ts apps/server/src/
git commit -m "fix(relay): wire trace store into publish pipeline for delivery tracking"Task 2: Add subject label resolver utility
Files:
- Create:
apps/server/src/services/relay/subject-resolver.ts - Test:
apps/server/src/services/relay/__tests__/subject-resolver.test.ts
Context:
- Subject patterns:
relay.agent.{sessionId},relay.human.console.{clientId},relay.system.pulse.{scheduleId},relay.system.console - Agent resolution: session ID → TranscriptReader.getSession() → cwd → readManifest(cwd) → manifest.name
- TranscriptReader is at
apps/server/src/services/core/transcript-reader.ts - readManifest is at
packages/shared/src/manifest.ts
Step 1: Write the failing test
import { describe, it, expect, vi } from 'vitest';
import { resolveSubjectLabel, type SubjectLabel } from '../subject-resolver.js';
describe('resolveSubjectLabel', () => {
it('resolves relay.human.console.* to "You"', async () => {
const result = await resolveSubjectLabel('relay.human.console.abc-123', {});
expect(result).toEqual({ label: 'You', raw: 'relay.human.console.abc-123' });
});
it('resolves relay.system.console to "System Console"', async () => {
const result = await resolveSubjectLabel('relay.system.console', {});
expect(result).toEqual({ label: 'System Console', raw: 'relay.system.console' });
});
it('resolves relay.system.pulse.* to "Pulse Scheduler"', async () => {
const result = await resolveSubjectLabel('relay.system.pulse.sched-1', {});
expect(result).toEqual({ label: 'Pulse Scheduler', raw: 'relay.system.pulse.sched-1' });
});
it('resolves relay.agent.{sessionId} to agent name when available', async () => {
const mockGetSession = vi.fn().mockResolvedValue({ cwd: '/path/to/project' });
const mockReadManifest = vi.fn().mockResolvedValue({ name: 'Obsidian Repo' });
const result = await resolveSubjectLabel('relay.agent.abc-123-def', {
getSession: mockGetSession,
readManifest: mockReadManifest,
});
expect(result).toEqual({ label: 'Obsidian Repo', raw: 'relay.agent.abc-123-def' });
});
it('falls back to truncated session ID when agent not found', async () => {
const mockGetSession = vi.fn().mockResolvedValue({ cwd: '/path' });
const mockReadManifest = vi.fn().mockResolvedValue(null);
const result = await resolveSubjectLabel('relay.agent.abc-123-def', {
getSession: mockGetSession,
readManifest: mockReadManifest,
});
expect(result).toEqual({ label: 'Agent (abc-123)', raw: 'relay.agent.abc-123-def' });
});
it('falls back gracefully when session lookup fails', async () => {
const mockGetSession = vi.fn().mockRejectedValue(new Error('not found'));
const result = await resolveSubjectLabel('relay.agent.abc-123-def', {
getSession: mockGetSession,
});
expect(result).toEqual({ label: 'Agent (abc-123)', raw: 'relay.agent.abc-123-def' });
});
});Step 2: Run test to verify it fails
Run: pnpm vitest run apps/server/src/services/relay/__tests__/subject-resolver.test.ts
Expected: FAIL — module not found
Step 3: Implement subject-resolver.ts
/**
* Resolve relay subject strings into human-readable labels.
*
* @module services/relay/subject-resolver
*/
export interface SubjectLabel {
label: string;
raw: string;
}
interface ResolverDeps {
getSession?: (sessionId: string) => Promise<{ cwd?: string } | null>;
readManifest?: (cwd: string) => Promise<{ name?: string } | null>;
}
const SESSION_ID_PREVIEW_LENGTH = 7;
/**
* Resolve a relay subject string into a human-readable label.
*
* @param subject - Raw relay subject string
* @param deps - Optional dependency injection for session/manifest lookup
*/
export async function resolveSubjectLabel(
subject: string,
deps: ResolverDeps,
): Promise<SubjectLabel> {
const raw = subject;
// Static patterns
if (subject === 'relay.system.console') {
return { label: 'System Console', raw };
}
if (subject.startsWith('relay.system.pulse.')) {
return { label: 'Pulse Scheduler', raw };
}
if (subject.startsWith('relay.human.console.')) {
return { label: 'You', raw };
}
// Agent pattern — resolve name from manifest
if (subject.startsWith('relay.agent.')) {
const sessionId = subject.slice('relay.agent.'.length);
const shortId = sessionId.slice(0, SESSION_ID_PREVIEW_LENGTH);
const fallback: SubjectLabel = { label: `Agent (${shortId})`, raw };
if (!deps.getSession) return fallback;
try {
const session = await deps.getSession(sessionId);
if (!session?.cwd || !deps.readManifest) return fallback;
const manifest = await deps.readManifest(session.cwd);
if (!manifest?.name) return fallback;
return { label: manifest.name, raw };
} catch {
return fallback;
}
}
// Unknown pattern
return { label: subject, raw };
}
/**
* Batch-resolve multiple subjects, deduplicating lookups.
*
* @param subjects - Array of raw subject strings
* @param deps - Dependency injection for session/manifest lookup
*/
export async function resolveSubjectLabels(
subjects: string[],
deps: ResolverDeps,
): Promise<Map<string, SubjectLabel>> {
const unique = [...new Set(subjects)];
const results = await Promise.all(
unique.map(async (s) => [s, await resolveSubjectLabel(s, deps)] as const),
);
return new Map(results);
}Step 4: Run tests
Run: pnpm vitest run apps/server/src/services/relay/__tests__/subject-resolver.test.ts
Expected: All pass
Step 5: Commit
git add apps/server/src/services/relay/subject-resolver.ts apps/server/src/services/relay/__tests__/subject-resolver.test.ts
git commit -m "feat(relay): add subject label resolver for human-readable names"Task 3: Add GET /relay/conversations endpoint
Files:
- Modify:
apps/server/src/routes/relay.ts:55-63(add new route) - Modify:
packages/shared/src/relay-schemas.ts(add ConversationSchema) - Modify:
packages/shared/src/transport.ts:131-137(add listRelayConversations) - Modify:
apps/client/src/layers/shared/lib/http-transport.ts:334-349(add HTTP call) - Create:
apps/client/src/layers/entities/relay/model/use-relay-conversations.ts - Modify:
apps/client/src/layers/entities/relay/index.ts(export new hook)
Context:
- RelayCore exposes
listMessages()for SQLite index queries andgetMessage()for single messages - Maildir envelopes can be read via RelayCore's internal maildirStore (need to add a public method or read from the endpoint-level data)
- The grouping logic correlates
relay.agent.*(requests) withrelay.human.console.*(response chunks)
Step 1: Add Zod schema in relay-schemas.ts
Add to packages/shared/src/relay-schemas.ts:
export const SubjectLabelSchema = z.object({
label: z.string(),
raw: z.string(),
});
export const RelayConversationSchema = z.object({
id: z.string(),
direction: z.enum(['outbound', 'inbound']),
status: z.enum(['delivered', 'failed', 'pending']),
from: SubjectLabelSchema,
to: SubjectLabelSchema,
preview: z.string(),
payload: z.unknown().optional(),
responseCount: z.number(),
sentAt: z.string(),
completedAt: z.string().optional(),
durationMs: z.number().optional(),
subject: z.string(),
sessionId: z.string().optional(),
clientId: z.string().optional(),
traceId: z.string().optional(),
failureReason: z.string().optional(),
});
export type RelayConversation = z.infer<typeof RelayConversationSchema>;Step 2: Add Transport method
In packages/shared/src/transport.ts, add after listRelayMessages (~line 137):
/** List relay conversations (grouped request/response exchanges). */
listRelayConversations(): Promise<{ conversations: RelayConversation[] }>;Import the type at the top.
Step 3: Add HTTP transport implementation
In apps/client/src/layers/shared/lib/http-transport.ts, add after listRelayMessages:
async listRelayConversations() {
const res = await this.fetch('/api/relay/conversations');
return res.json();
},Step 4: Add server route
In apps/server/src/routes/relay.ts, add after the GET /messages route (~line 63):
// GET /conversations — Grouped request/response exchanges with human labels
router.get('/conversations', async (_req, res) => {
try {
const messages = relayCore.listMessages({});
const deadLetters = relayCore.getDeadLetters();
// Import resolver
const { resolveSubjectLabels } = await import(
'../services/relay/subject-resolver.js'
);
const { transcriptReader } = await import(
'../services/core/transcript-reader.js'
);
const { readManifest } = await import('@dorkos/shared/manifest');
// Collect all unique subjects
const allSubjects = [
...messages.messages.map((m: { subject: string }) => m.subject),
];
const resolverDeps = {
getSession: async (id: string) => transcriptReader.getSession(id),
readManifest: async (cwd: string) => readManifest(cwd),
};
const labelMap = await resolveSubjectLabels(allSubjects, resolverDeps);
// Separate requests (relay.agent.*) from response chunks (relay.human.console.*)
const requests: Array<Record<string, unknown>> = [];
const responseChunksBySubject = new Map<string, Array<Record<string, unknown>>>();
for (const msg of messages.messages as Array<Record<string, unknown>>) {
const subject = msg.subject as string;
if (subject.startsWith('relay.agent.') || subject.startsWith('relay.system.')) {
requests.push(msg);
} else if (subject.startsWith('relay.human.console.')) {
const existing = responseChunksBySubject.get(subject) ?? [];
existing.push(msg);
responseChunksBySubject.set(subject, existing);
}
}
// Build conversations from requests
const conversations = requests.map((req) => {
const subject = req.subject as string;
const messageId = req.id as string;
const status = req.status as string;
const createdAt = req.createdAt as string;
// Extract session ID from subject
const sessionId = subject.startsWith('relay.agent.')
? subject.slice('relay.agent.'.length)
: undefined;
// Find response chunks (matched by the request's from field being the response subject)
// For now, use the most recent relay.human.console.* group
const fromSubject = req.from as string | undefined;
const responseChunks = fromSubject
? (responseChunksBySubject.get(fromSubject) ?? [])
: [];
const lastChunk = responseChunks[0]; // messages are sorted newest-first
// Resolve dead letter info
const deadLetter = deadLetters.find(
(dl: { messageId: string }) => dl.messageId === messageId,
);
// Build preview from dead letter envelope (has full payload) or from the message
let preview = '';
let payload: unknown = undefined;
if (deadLetter?.envelope?.payload) {
payload = deadLetter.envelope.payload;
const p = payload as Record<string, unknown>;
const text = p?.content ?? p?.text ?? p?.message;
preview = typeof text === 'string' ? text.slice(0, 120) : JSON.stringify(payload).slice(0, 120);
}
const fromLabel = labelMap.get(fromSubject ?? '') ?? { label: 'Unknown', raw: fromSubject ?? '' };
const toLabel = labelMap.get(subject) ?? { label: subject, raw: subject };
return {
id: messageId,
direction: 'outbound' as const,
status: status === 'cur' || status === 'delivered' ? 'delivered' as const
: status === 'failed' ? 'failed' as const
: 'pending' as const,
from: fromLabel,
to: toLabel,
preview,
payload,
responseCount: responseChunks.length,
sentAt: createdAt,
completedAt: lastChunk?.createdAt as string | undefined,
durationMs: lastChunk
? new Date(lastChunk.createdAt as string).getTime() - new Date(createdAt).getTime()
: undefined,
subject,
sessionId,
traceId: messageId,
failureReason: deadLetter?.reason as string | undefined,
};
});
return res.json({ conversations });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to build conversations';
return res.status(500).json({ error: message });
}
});Step 5: Add client hook
Create apps/client/src/layers/entities/relay/model/use-relay-conversations.ts:
import { useQuery } from '@tanstack/react-query';
import { useTransport } from '@/layers/shared/model';
import type { RelayConversation } from '@dorkos/shared/relay-schemas';
export const RELAY_CONVERSATIONS_KEY = ['relay', 'conversations'] as const;
/** Fetch grouped relay conversations with human-readable labels. */
export function useRelayConversations(enabled = true) {
const transport = useTransport();
return useQuery<{ conversations: RelayConversation[] }>({
queryKey: [...RELAY_CONVERSATIONS_KEY],
queryFn: () => transport.listRelayConversations(),
enabled,
refetchInterval: 5000,
});
}Step 6: Export from barrel
In apps/client/src/layers/entities/relay/index.ts, add:
export { useRelayConversations } from './model/use-relay-conversations';Step 7: Run typecheck
Run: pnpm typecheck
Expected: Clean across all packages
Step 8: Commit
git add packages/shared/src/relay-schemas.ts packages/shared/src/transport.ts apps/client/src/layers/shared/lib/http-transport.ts apps/server/src/routes/relay.ts apps/client/src/layers/entities/relay/
git commit -m "feat(relay): add conversations endpoint with grouped messages and human labels"Task 4: Create ConversationRow component
Files:
- Create:
apps/client/src/layers/features/relay/ui/ConversationRow.tsx - Modify:
apps/client/src/layers/features/relay/ui/ActivityFeed.tsx(swap MessageRow for ConversationRow)
Context:
- Current MessageRow is at
apps/client/src/layers/features/relay/ui/MessageRow.tsx(158 lines) - Uses motion/react for expand/collapse, Badge from shared/ui, MessageTrace for trace view
- Status colors from
../lib/status-colors.ts - Design: collapsed shows "You → Agent Name", preview, status. Expanded shows payload, delivery timing, technical accordion, trace accordion.
Step 1: Create ConversationRow.tsx
import { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Check,
AlertTriangle,
Clock,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Badge } from '@/layers/shared/ui';
import { cn } from '@/layers/shared/lib';
import { MessageTrace } from './MessageTrace';
import { getStatusBorderColor } from '../lib/status-colors';
import type { RelayConversation } from '@dorkos/shared/relay-schemas';
interface ConversationRowProps {
conversation: RelayConversation;
}
const STATUS_CONFIG = {
delivered: { icon: Check, className: 'text-green-600 dark:text-green-400', label: 'Delivered', dot: 'bg-green-500' },
failed: { icon: AlertTriangle, className: 'text-destructive', label: 'Failed', dot: 'bg-red-500' },
pending: { icon: Clock, className: 'text-muted-foreground', label: 'Pending', dot: 'bg-blue-500' },
} as const;
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function formatTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
/** Conversation card with progressive disclosure: human labels → payload → technical details. */
export function ConversationRow({ conversation }: ConversationRowProps) {
const [expanded, setExpanded] = useState(false);
const [showTechnical, setShowTechnical] = useState(false);
const [showTrace, setShowTrace] = useState(false);
const config = STATUS_CONFIG[conversation.status];
const borderColor = getStatusBorderColor(conversation.status);
const outcome = conversation.status === 'delivered'
? conversation.responseCount > 0
? `delivered · ${conversation.responseCount} chunks`
: 'delivered'
: conversation.failureReason ?? config.label.toLowerCase();
return (
<div
className={cn(
'w-full rounded-lg border border-l-2 text-left transition-colors hover:bg-muted/50 hover:shadow-sm',
borderColor,
expanded && 'bg-muted/30',
)}
>
{/* Collapsed view — human-readable only */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full p-3"
>
<div className="flex items-center gap-2">
<span className={cn('size-2 shrink-0 rounded-full', config.dot)} />
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{conversation.from.label}
<span className="mx-1.5 text-muted-foreground">→</span>
{conversation.to.label}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatTimeAgo(conversation.sentAt)}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
{conversation.preview && (
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
"{conversation.preview}"
</span>
)}
<span className={cn('shrink-0 text-xs', config.className)}>
{outcome}
</span>
</div>
</button>
{/* Expanded view — payload + delivery details */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="space-y-3 border-t px-3 pb-3 pt-3">
{/* Payload */}
{conversation.payload != null && (
<div>
<span className="text-xs font-medium text-muted-foreground">Payload</span>
<pre className="mt-1 max-h-40 overflow-auto rounded bg-muted p-2 font-mono text-xs">
{JSON.stringify(conversation.payload, null, 2)}
</pre>
</div>
)}
{/* Delivery timing */}
<div className="text-xs text-muted-foreground">
<span>Sent {formatTime(conversation.sentAt)}</span>
{conversation.completedAt && (
<span> · Completed {formatTime(conversation.completedAt)}</span>
)}
{conversation.durationMs != null && (
<span> · Duration: {formatDuration(conversation.durationMs)}</span>
)}
{conversation.responseCount > 0 && (
<span> · {conversation.responseCount} response chunks</span>
)}
</div>
{/* Failure reason */}
{conversation.failureReason && (
<div className="rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
{conversation.failureReason}
</div>
)}
{/* Technical Details accordion */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); setShowTechnical(!showTechnical); }}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
{showTechnical ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
Technical Details
</button>
<AnimatePresence initial={false}>
{showTechnical && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-muted-foreground">Subject</dt>
<dd className="truncate font-mono">{conversation.subject}</dd>
{conversation.sessionId && (
<>
<dt className="text-muted-foreground">Session</dt>
<dd className="font-mono">{conversation.sessionId.slice(0, 8)}</dd>
</>
)}
{conversation.traceId && (
<>
<dt className="text-muted-foreground">Trace ID</dt>
<dd className="font-mono">{conversation.traceId.slice(0, 12)}…</dd>
</>
)}
<dt className="text-muted-foreground">From (raw)</dt>
<dd className="truncate font-mono">{conversation.from.raw}</dd>
<dt className="text-muted-foreground">To (raw)</dt>
<dd className="truncate font-mono">{conversation.to.raw}</dd>
</dl>
</motion.div>
)}
</AnimatePresence>
{/* Trace Timeline accordion */}
{conversation.traceId && (
<>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setShowTrace(!showTrace); }}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
{showTrace ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
Trace Timeline
</button>
<AnimatePresence initial={false}>
{showTrace && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<MessageTrace messageId={conversation.traceId} onClose={() => setShowTrace(false)} />
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}Step 2: Swap MessageRow for ConversationRow in ActivityFeed
In apps/client/src/layers/features/relay/ui/ActivityFeed.tsx:
- Replace
import { MessageRow } from './MessageRow'withimport { ConversationRow } from './ConversationRow' - Replace
useRelayMessageswithuseRelayConversationsfrom the entities barrel - Replace the MessageRow render loop with ConversationRow, passing
conversationprop - Update the filter logic to work on
RelayConversationfields instead of raw message fields - Keep the existing animation logic (initialIdsRef, motion.div wrapper)
The source filter maps to conversation fields:
- "Chat messages" →
conversation.subject.startsWith('relay.agent.') - "Pulse jobs" →
conversation.subject.startsWith('relay.system.pulse.') - "System" →
conversation.subject.startsWith('relay.system.') && !conversation.subject.startsWith('relay.system.pulse.')
The status filter maps directly to conversation.status.
The search filter matches against conversation.from.label, conversation.to.label, conversation.preview, or conversation.subject.
Step 3: Run typecheck and dev server
Run: pnpm typecheck
Then: pnpm dev and verify in browser
Step 4: Commit
git add apps/client/src/layers/features/relay/ui/ConversationRow.tsx apps/client/src/layers/features/relay/ui/ActivityFeed.tsx
git commit -m "feat(relay): replace MessageRow with ConversationRow for human-readable activity feed"Task 5: Improve DeadLetterSection with human labels
Files:
- Modify:
apps/client/src/layers/features/relay/ui/DeadLetterSection.tsx
Context:
- Dead letter API response already includes the full envelope with payload and subject
- Current DeadLetterRow shows message ID and reason, but no human-readable info
- The dead letter data has
envelope.subject,envelope.payload,envelope.from
Step 1: Update DeadLetterRow to show human labels
In DeadLetterSection.tsx, update the DeadLetterRow component:
- Extract preview from
dl.envelope.payload.content(or fallback to JSON preview) - Parse the target from
dl.envelope.subjectusing the same static label patterns (client-side is fine here since we already have the full envelope):relay.agent.{id}→Agent ({id.slice(0,7)})relay.system.pulse.*→Pulse Scheduler- etc.
- Show preview text + target label instead of hash ID
- Keep the failure reason badge
- Keep the expandable JSON detail for power users
The collapsed row should show:
"hello" → Agent (a6010b) No matching endpoints 4h agoInstead of:
01KJG7Z6ZQAFXRTMB1WQKS1MQM Unknown 4h agoStep 2: Run dev server and verify
Step 3: Commit
git add apps/client/src/layers/features/relay/ui/DeadLetterSection.tsx
git commit -m "feat(relay): show human-readable labels and message preview in dead letters"Task 6: Improve EndpointList with human labels
Files:
- Modify:
apps/client/src/layers/features/relay/ui/EndpointList.tsx
Context:
- Endpoint data includes
subject,hash,registeredAt - Apply the same static subject parsing client-side
- Show human label as primary text, raw subject as secondary monospace
Step 1: Add a client-side label resolver
Create a small utility in apps/client/src/layers/features/relay/lib/resolve-label.ts:
/** Resolve a relay subject to a human-friendly label (client-side, no server calls). */
export function resolveSubjectLabelLocal(subject: string): string {
if (subject === 'relay.system.console') return 'System Console';
if (subject.startsWith('relay.system.pulse.')) return 'Pulse Scheduler';
if (subject.startsWith('relay.human.console.')) return 'Your Browser Session';
if (subject.startsWith('relay.agent.')) {
const id = subject.slice('relay.agent.'.length);
return `Agent (${id.slice(0, 7)})`;
}
return subject;
}Step 2: Update EndpointList to show human label above raw subject
In the endpoint card, render:
- Human label as
text-sm font-medium - Raw subject below as
text-xs font-mono text-muted-foreground truncate
Step 3: Update AdapterCard description
In AdapterCard.tsx, for the Claude Code adapter, replace "In: N | Out: N" with a more descriptive line like "Handles: Chat messages, Pulse jobs" when the adapter's subject prefixes are relay.agent.* and relay.system.pulse.*.
Step 4: Run dev server and verify
Step 5: Commit
git add apps/client/src/layers/features/relay/lib/resolve-label.ts apps/client/src/layers/features/relay/ui/EndpointList.tsx apps/client/src/layers/features/relay/ui/AdapterCard.tsx
git commit -m "feat(relay): add human-readable labels to endpoints and adapter descriptions"Task 7: Update filter labels and search placeholder
Files:
- Modify:
apps/client/src/layers/features/relay/ui/ActivityFeed.tsx
Context:
- Current source filter options: derived from subject prefixes with technical names
- Current search placeholder: "Filter by subject..."
Step 1: Update filter labels
Rename source filter options:
- "All" stays
- TG/Telegram → (keep if present)
- WH/Webhook → (keep if present)
- SYS → "System"
- Add "Chat" for
relay.agent.*andrelay.human.console.*
Rename status filter options:
- "All" stays
- "New" → "Pending"
- "Cur" → "Delivered"
- "Failed" stays
- "Dead Letter" → "Failed"
Update search placeholder: "Filter by agent or message..."
Step 2: Verify in browser
Step 3: Commit
git add apps/client/src/layers/features/relay/ui/ActivityFeed.tsx
git commit -m "feat(relay): rename filter labels to human-friendly terms"Task 8: Final integration test and cleanup
Files:
- Modify:
apps/client/src/layers/features/relay/ui/MessageRow.tsx(keep but no longer imported by ActivityFeed — verify unused or remove) - Run: Full test suite
Step 1: Run full test suite
Run: pnpm test -- --run
Expected: All pass
Step 2: Run typecheck
Run: pnpm typecheck
Expected: Clean
Step 3: Run lint
Run: pnpm lint
Expected: No new errors
Step 4: Manual browser verification
Start dev server (pnpm dev), open Relay panel:
- Activity tab shows ConversationRow with "You → Agent Name" labels
- Expanding a conversation shows payload (not "undefined")
- Expanding a conversation shows delivery timing
- Technical Details accordion shows raw subjects
- Trace Timeline accordion loads (not "Failed to load trace")
- Dead letters show message preview and target name
- Endpoints show human labels above raw subjects
- Filters use friendly labels
- Send a test message via Compose and verify the full flow
Step 5: Remove MessageRow if unused
If MessageRow is no longer imported anywhere, remove it to avoid dead code. Check with grep first.
Step 6: Final commit
git add -A
git commit -m "feat(relay): complete conversation view with human labels, payload display, and trace fixes"