From 008da6d19a0399e71b685eeb68f805d5fc240221 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Fri, 4 Sep 2026 08:39:09 +0200 Subject: [PATCH] feat(dashboard): link a log entry's Conversation Context to its owning conversation (#12646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado sobre o tip de `release/v3.8.51`, com duas coisas resolvidas antes do merge. **A falha de CI era stale.** O job `No new ESLint warnings` deste PR apontava `react-hooks/set-state-in-effect` em `src/app/(dashboard)/dashboard/combos/page.tsx:774` — arquivo que este PR não toca, e o mesmo erro aparecia em #12668 e #12672, que também não o tocam. A linha do tempo: o #12355 introduziu a violação de manhã, os CIs rodaram nessa janela, e o #12607 acrescentou a entrada de supressão à tarde. Medido no tip atual com o comando exato do job: **0 ocorrências não suprimidas**. A supressão sobrevivente é "unpruned", e o script passa `--pass-on-unpruned-suppressions` justamente para isso não bloquear. **Faltava o teste que a regra do projeto exige** para mudanças em `src/`. Acrescentei `tests/unit/ui/log-detail-conversation-link-12646.test.tsx`, verificado **RED-then-GREEN** em vez de escrito contra o código pronto: revertendo `RequestLoggerDetail.sections.tsx` para o tip, 2 dos 3 casos falham; com a mudança deste PR, 3/3 passam. Detalhe que valeu a pena descobrir: a seção curto-circuita em `allTurns.length === 0`, então o fixture precisa de um `requestBody` que normalize em pelo menos um turno — sem isso o cabeçalho inteiro nunca monta e as asserções passariam pelo motivo errado. O teste fixa três coisas: o href para um `sessionTag` simples, o percent-encoding para um que não é URL-safe, e a ausência de link quando não há `sessionTag`. Obrigado, @hartmark. --- .../RequestLoggerDetail.sections.tsx | 15 +++ ...og-detail-conversation-link-12646.test.tsx | 106 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/unit/ui/log-detail-conversation-link-12646.test.tsx diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx index 78527ab495..cbdce62a01 100644 --- a/src/shared/components/RequestLoggerDetail.sections.tsx +++ b/src/shared/components/RequestLoggerDetail.sections.tsx @@ -273,6 +273,21 @@ export function ConversationContextSection({ log, detail }) { continues from parent )} + {liveDetail?.sessionTag && ( + // /dashboard/conversations reads its own `?tree=` deep-link param + // from a fresh mount too (useState(() => searchParams.get("tree")) in + // that page) -- same full-navigation reasoning as the parent-log link + // above. sessionTag is the same conv_ the conversations list and + // /api/conversations/[id]/tree both key on. + + forum + view conversation + + )} {open && (
diff --git a/tests/unit/ui/log-detail-conversation-link-12646.test.tsx b/tests/unit/ui/log-detail-conversation-link-12646.test.tsx new file mode 100644 index 0000000000..9a8ad2eeba --- /dev/null +++ b/tests/unit/ui/log-detail-conversation-link-12646.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +/** + * Guard for #12646: a log entry's Conversation Context header links to the + * conversation that owns it. + * + * The link is a plain `` on purpose, not a client-side route push: + * /dashboard/conversations reads its own `?tree=` deep-link param from a + * fresh mount (`useState(() => searchParams.get("tree"))`), so it only picks + * the param up on a full navigation. A future refactor to a Next `` + * would silently stop opening the right tree — which is what this test pins. + */ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const RequestLoggerDetail = (await import("../../../src/shared/components/RequestLoggerDetail.tsx")) + .default; + +let container: HTMLElement; +let root: Root; + +function baseLog(overrides: Record = {}) { + return { + id: "log-12646", + status: 200, + method: "POST", + path: "/v1/chat/completions", + model: "gpt-test", + provider: "openai", + timestamp: new Date().toISOString(), + duration: 42, + tokens: { in: 1, out: 2 }, + ...overrides, + }; +} + +const noop = () => {}; + +// The section short-circuits with `if (allTurns.length === 0) return null`, so the +// fixture needs a request body that normalizes into at least one turn — otherwise +// the whole header, link included, never mounts and the assertions would pass or +// fail for the wrong reason. +const REQUEST_BODY_WITH_A_TURN = { + model: "gpt-test", + messages: [{ role: "user", content: "hello" }], +}; + +async function renderDetail(detail: Record) { + const log = baseLog(); + await act(async () => { + root.render( + true} + /> + ); + }); +} + +function conversationLink(): HTMLAnchorElement | null { + return container.querySelector('a[href^="/dashboard/conversations?tree="]'); +} + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + if (root) { + await act(async () => { + root.unmount(); + }); + } + container?.remove(); +}); + +describe("log detail — Conversation Context link (#12646)", () => { + it("deep-links to the owning conversation when the detail carries a sessionTag", async () => { + await renderDetail({ sessionTag: "conv_abc123" }); + + const link = conversationLink(); + expect(link).not.toBeNull(); + expect(link!.getAttribute("href")).toBe("/dashboard/conversations?tree=conv_abc123"); + }); + + it("percent-encodes a sessionTag that is not URL-safe", async () => { + await renderDetail({ sessionTag: "conv_a b/c?d" }); + + expect(conversationLink()!.getAttribute("href")).toBe( + `/dashboard/conversations?tree=${encodeURIComponent("conv_a b/c?d")}` + ); + }); + + it("renders no conversation link when the detail has no sessionTag", async () => { + await renderDetail({}); + + expect(conversationLink()).toBeNull(); + }); +});