Link learning trajectories to their conversation

The trajectory detail panel showed the conversation id as plain text, so
reviewing an example meant copying the room id and hunting for it in the
inbox — with the evidence panel open on one screen and the conversation on
another.

Render it as a link instead. The id has to travel as an owned id
(accountId~conversationId): once account runtimes exist, findConversation
rejects a bare room id outright, so a plain link would 404 for every
multi-account install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-12 08:58:04 +01:00
1 parent 142378df33
commit 3f60f7f665
3 files changed
+32 -1

No files matched your search

@@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
import { learningApi } from './learning-api.js'; import { learningApi } from './learning-api.js';
import './learning.css'; import './learning.css';
@@ -13,7 +15,19 @@ function TrajectoryDetail({ id }: { id: string }) {
return ( return (
<div className="trajectory-detail"> <div className="trajectory-detail">
<dl className="trajectory-facts"> <dl className="trajectory-facts">
<div><dt>Conversation</dt><dd>{trajectory.conversationId}</dd></div> <div>
<dt>Conversation</dt>
<dd>
{/* The conversations route resolves owned ids only, so the account
that produced the trajectory has to travel with the room id. */}
<Link
className="trajectory-conversation-link"
to={`/conversations/${encodeURIComponent(createOwnedConversationId(trajectory.accountId, trajectory.conversationId))}`}
>
{trajectory.conversationId}
</Link>
</dd>
</div>
<div><dt>Policy mode</dt><dd>{trajectory.policyMode ?? 'Unknown'}</dd></div> <div><dt>Policy mode</dt><dd>{trajectory.policyMode ?? 'Unknown'}</dd></div>
<div><dt>Outcome</dt><dd>{trajectory.outcomeType.replaceAll('-', ' ')}</dd></div> <div><dt>Outcome</dt><dd>{trajectory.outcomeType.replaceAll('-', ' ')}</dd></div>
<div><dt>Matures</dt><dd>{new Date(trajectory.maturityAt).toLocaleString()}</dd></div> <div><dt>Matures</dt><dd>{new Date(trajectory.maturityAt).toLocaleString()}</dd></div>
@@ -14,6 +14,7 @@
.trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; } .trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; }
.trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; } .trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
.trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; } .trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; }
.trajectory-conversation-link { color: var(--accent); text-decoration: underline; text-underline-offset: .15em; }
.decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; } .decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
.decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; } .decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; }
.decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; } .decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; }
+16
View File
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw'; import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node';
import { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js'; import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js';
@@ -62,7 +63,9 @@ function renderLearning() {
}); });
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter>
<LearningPage /> <LearningPage />
</MemoryRouter>
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -96,6 +99,19 @@ describe('learning page trajectory detail', () => {
await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID])); await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID]));
}); });
it('links the evidence to the owning account\'s conversation', async () => {
renderLearning();
await screen.findByText(/human created success/i);
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
const link = await screen.findByRole('link', { name: '!room:reddit.com' });
// The account id has to survive in the owned id or the route 404s.
expect(link).toHaveAttribute(
'href',
`/conversations/${encodeURIComponent('%40t2_learner%3Areddit.com~!room%3Areddit.com')}`,
);
});
it('collapses the evidence again on a second click', async () => { it('collapses the evidence again on a second click', async () => {
renderLearning(); renderLearning();
await screen.findByText(/human created success/i); await screen.findByText(/human created success/i);