feat(frontend): viewer humain fil-par-paire en lecture seule (LS7)

Frontend pur, lecture seule, consomme read_conversation_page (LS6) ; aucun changement backend.
- adapters : conversation.ts (gateway) + conversationNormalization.ts.
- features/conversations : useConversationThread, ConversationViewer, index.
- domain (types LS7), ports (port + Gateways.conversation), adapters/index, mock (+ clé inventaire).
- features/workstate : ProjectWorkStatePanel prop onOpenConversation.
- features/projects : ProjectsView swap viewer.
- tests (QA, verts) : conversationNormalization, mock/conversationGateway,
  useConversationThread, ConversationViewer, ProjectsView.ls7. tsc 0, vitest 443/443 (+36).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:30:51 +02:00
parent 40ca3e522f
commit c19e375849
17 changed files with 1552 additions and 5 deletions

View File

@ -21,6 +21,8 @@ import { useProjectWorkState } from "./useProjectWorkState";
export interface ProjectWorkStatePanelProps {
projectId: string;
/** Opens the read-only transcript viewer for a conversation pair (LS7). */
onOpenConversation?: (conversationId: string) => void;
}
interface WorkStatePanelErrorBoundaryProps {
@ -133,8 +135,10 @@ async function copyToClipboard(text: string): Promise<void> {
function ConversationSummaryLine({
summary,
onOpenConversation,
}: {
summary: ConversationWorkSummary;
onOpenConversation?: (conversationId: string) => void;
}) {
const text = summaryText(summary);
const [expanded, setExpanded] = useState(false);
@ -153,7 +157,18 @@ function ConversationSummaryLine({
>
{summaryLabel(summary.status)}
</span>
<span className="min-w-0 flex-1 break-words">{text}</span>
{onOpenConversation ? (
<button
type="button"
aria-label={`open conversation ${summary.conversationId}`}
className="min-w-0 flex-1 break-words text-left hover:text-content hover:underline"
onClick={() => onOpenConversation(summary.conversationId)}
>
{text}
</button>
) : (
<span className="min-w-0 flex-1 break-words">{text}</span>
)}
<button
type="button"
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content"
@ -211,9 +226,11 @@ function ConversationSummaryLine({
function TicketRow({
ticket,
summary,
onOpenConversation,
}: {
ticket: AgentTicketState;
summary?: ConversationWorkSummary;
onOpenConversation?: (conversationId: string) => void;
}) {
const truncated = ticket.taskLen > ticket.taskPreview.length;
return (
@ -251,7 +268,12 @@ function TicketRow({
{shortTicket(ticket.ticketId)}
</code>
</div>
{summary && <ConversationSummaryLine summary={summary} />}
{summary && (
<ConversationSummaryLine
summary={summary}
onOpenConversation={onOpenConversation}
/>
)}
</li>
);
}
@ -263,6 +285,7 @@ function AgentRow({
layout,
projectId,
onRefresh,
onOpenConversation,
}: {
agent: AgentWorkState;
conversations: Map<string, ConversationWorkSummary>;
@ -270,6 +293,7 @@ function AgentRow({
layout: LayoutViewModel;
projectId: string;
onRefresh: () => Promise<void>;
onOpenConversation?: (conversationId: string) => void;
}) {
const { agent: agentGateway } = useGateways();
const [message, setMessage] = useState<string | null>(null);
@ -431,6 +455,7 @@ function AgentRow({
key={ticket.ticketId}
ticket={ticket}
summary={conversations.get(ticket.conversationId)}
onOpenConversation={onOpenConversation}
/>
))}
</ul>
@ -439,7 +464,10 @@ function AgentRow({
);
}
function ProjectWorkStatePanelContent({ projectId }: ProjectWorkStatePanelProps) {
function ProjectWorkStatePanelContent({
projectId,
onOpenConversation,
}: ProjectWorkStatePanelProps) {
const vm = useProjectWorkState(projectId);
const layoutVm = useLayout(projectId);
const agents = vm.state?.agents ?? [];
@ -491,6 +519,7 @@ function ProjectWorkStatePanelContent({ projectId }: ProjectWorkStatePanelProps)
layout={layoutVm}
projectId={projectId}
onRefresh={vm.refresh}
onOpenConversation={onOpenConversation}
/>
))}
</ul>