diff --git a/apps/portal/src/pages/RunDetail.stories.tsx b/apps/portal/src/pages/RunDetail.stories.tsx
new file mode 100644
index 000000000..28850f7e9
--- /dev/null
+++ b/apps/portal/src/pages/RunDetail.stories.tsx
@@ -0,0 +1,93 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { Navigate, Route, Routes } from "react-router-dom";
+import { http, HttpResponse } from "msw";
+import { expect } from "storybook/test";
+import type { Run } from "@/types";
+import { RunDetail } from "./RunDetail";
+
+const run: Run = {
+ _id: "demo-tab-order",
+ id: "demo-tab-order",
+ workerType: "coder-acp-copilot",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ run: {
+ _id: "attempt-1",
+ attemptNumber: 1,
+ status: "done",
+ outcome: "succeeded",
+ harUrl: "demo.har",
+ videoUrls: ["demo.webm"],
+ turns: [{
+ iteration: 1,
+ codingAgentResponse: "Demo task completed.",
+ judgeFeedback: "",
+ snapshotUrl: "",
+ passed: true,
+ timestamp: "2026-01-01T00:00:00.000Z",
+ }],
+ },
+};
+
+const meta = {
+ title: "Pages/RunDetail",
+ component: RunDetail,
+ tags: ["ai-generated", "needs-work"],
+ parameters: {
+ layout: "padded",
+ msw: {
+ handlers: [
+ http.get("/api/v1/requests/demo-tab-order/reports", () => HttpResponse.json([])),
+ http.get("/api/v1/requests/demo-tab-order/logs", () =>
+ new HttpResponse("event: done\ndata: {}\n\n", {
+ headers: { "Content-Type": "text/event-stream" },
+ }),
+ ),
+ ],
+ },
+ },
+ decorators: [
+ (Story, context) => {
+ const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
+ client.setQueryData(["run", run._id], context.parameters.emptyRun
+ ? { ...run, run: { ...run.run, status: "done", turns: [], harUrl: undefined, videoUrls: [] } }
+ : run);
+ client.setQueryData(["run-reports", run._id], []);
+ client.setQueryData(["report-templates"], []);
+ return (
+
+
+ } />
+ } />
+
+
+ );
+ },
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+type TabPlayContext = Pick>[0], "canvas">;
+
+export const AllTabs = {
+ play: async ({ canvas }: TabPlayContext) => {
+ await expect((await canvas.findAllByRole("tab")).map((tab) => tab.textContent)).toEqual([
+ "Details", "Turns (1)", "Conversation", "Reports ", "Network", "Tool Calls", "Videos (1)", "Logs",
+ ]);
+ await expect(canvas.getByRole("tab", { name: "Turns (1)" })).toHaveAttribute("aria-selected", "true");
+ },
+} satisfies Story;
+
+export const WithoutArtifacts = {
+ parameters: { emptyRun: true },
+ play: async ({ canvas }: TabPlayContext) => {
+ await expect((await canvas.findAllByRole("tab")).map((tab) => tab.textContent)).toEqual([
+ "Details", "Turns (0)", "Reports ", "Logs",
+ ]);
+ await expect(canvas.getByRole("tab", { name: "Logs" })).toHaveAttribute("aria-selected", "true");
+ },
+} satisfies Story;
diff --git a/apps/portal/src/pages/RunDetail.test.tsx b/apps/portal/src/pages/RunDetail.test.tsx
new file mode 100644
index 000000000..5fb0b934a
--- /dev/null
+++ b/apps/portal/src/pages/RunDetail.test.tsx
@@ -0,0 +1,135 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import type { Run, RunState } from "@/types";
+import { api } from "@/lib/api";
+import { RunDetail } from "./RunDetail";
+import { composeStories } from "@storybook/react-vite";
+import * as stories from "./RunDetail.stories";
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ getRun: vi.fn(),
+ getRunReports: vi.fn(async () => []),
+ listReportTemplates: vi.fn(async () => []),
+ logsUrl: vi.fn(),
+ archiveUrl: vi.fn(),
+ videoUrl: vi.fn(() => "/demo.webm"),
+ },
+}));
+vi.mock("@/hooks/use-log-stream", () => ({
+ useLogStream: () => ({
+ logs: [], isConnected: false, isDone: true, error: null,
+ }),
+}));
+vi.mock("@/components/TurnTimeline", () => ({ TurnTimeline: () => null }));
+
+const turn = {
+ iteration: 1,
+ judgeFeedback: "",
+ snapshotUrl: "",
+ passed: true,
+ timestamp: "2026-01-01T00:00:00.000Z",
+};
+
+function Location() {
+ const location = useLocation();
+ return ;
+}
+
+function renderDetail(state: Partial, path = "/runs/demo") {
+ const run: Run = {
+ _id: "demo",
+ id: "demo",
+ workerType: "coder-acp-copilot",
+ createdAt: turn.timestamp,
+ run: { _id: "attempt-1", attemptNumber: 1, status: "done", ...state },
+ };
+ vi.mocked(api.getRun).mockResolvedValue(run);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+
+ } />
+
+
+ ,
+ );
+}
+
+async function tabNames() {
+ const list = await screen.findByRole("tablist");
+ return within(list).getAllByRole("tab").map((tab) => tab.textContent);
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("RunDetail tabs", () => {
+ it("orders every tab with Videos before Logs and keeps Turns selected", async () => {
+ renderDetail({ turns: [turn], harUrl: "har.json", videoUrls: ["video.webm"] });
+
+ expect(await tabNames()).toEqual([
+ "Details", "Turns (1)", "Conversation", "Reports ", "Network", "Tool Calls", "Videos (1)", "Logs",
+ ]);
+ expect(screen.getByRole("tab", { name: "Turns (1)" }).getAttribute("aria-selected")).toBe("true");
+ });
+
+ describe("RunDetail stories", () => {
+ const { AllTabs, WithoutArtifacts } = composeStories(stories);
+
+ it("runs the all-tabs story assertions", async () => {
+ const { container } = render();
+ await stories.AllTabs.play?.({ canvas: within(container) });
+ });
+
+ it("runs the no-artifacts story assertions", async () => {
+ const { container } = render();
+ await stories.WithoutArtifacts.play?.({ canvas: within(container) });
+ });
+ });
+
+ it.each(["pending", "processing", "done"] as const)(
+ "hides artifact tabs without data and keeps Logs selected for a %s run without turns",
+ async (status) => {
+ renderDetail({ status });
+
+ expect(await tabNames()).toEqual(["Details", "Turns ", "Reports ", "Logs"]);
+ expect(screen.getByRole("tab", { name: "Logs" }).getAttribute("aria-selected")).toBe("true");
+ },
+ );
+
+ it("keeps Conversation visible without HAR or video data", async () => {
+ renderDetail({ turns: [turn] });
+
+ expect(await tabNames()).toEqual(["Details", "Turns (1)", "Conversation", "Reports ", "Logs"]);
+ });
+
+ it("preserves turn-level artifacts and setup videos", async () => {
+ renderDetail({ turns: [{ ...turn, harUrl: "turn.har", videoUrls: ["turn.webm"] }], setupVideoUrls: ["setup.webm"] });
+
+ expect(await tabNames()).toEqual([
+ "Details", "Turns (1)", "Conversation", "Reports ", "Network", "Tool Calls", "Videos (2)", "Logs",
+ ]);
+ });
+
+ it("honors explicit tab routes and preserves the attempt query when switching tabs", async () => {
+ renderDetail({ turns: [turn] }, "/runs/demo/reports?runId=attempt-1");
+ await tabNames();
+
+ expect(screen.getByRole("tab", { name: "Reports" }).getAttribute("aria-selected")).toBe("true");
+ fireEvent.mouseDown(screen.getByRole("tab", { name: "Details" }), { button: 0, ctrlKey: false });
+
+ expect(screen.getByTestId("location").textContent).toBe("/runs/demo/details?runId=attempt-1");
+ expect(screen.getByRole("tab", { name: "Details" }).getAttribute("aria-selected")).toBe("true");
+ });
+});
diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx
index 77ac6b0fa..27b0140ff 100644
--- a/apps/portal/src/pages/RunDetail.tsx
+++ b/apps/portal/src/pages/RunDetail.tsx
@@ -950,20 +950,20 @@ export function RunDetail() {
onValueChange={(value) => navigate(`/runs/${id}/${value}${selectedRunId ? `?runId=${selectedRunId}` : ""}`)}
>
+ Details
Turns {activeRun?.turns ? `(${activeRun?.turns.length})` : ""}
{activeRun?.turns && activeRun?.turns.length > 0 && (
Conversation
)}
+
+ Reports {reports && reports.length > 0 ? `(${reports.length})` : ""}
+
{hasHarData && Network}
{hasHarData && Tool Calls}
{hasVideoData && Videos ({videoCount})}
Logs
-
- Reports {reports && reports.length > 0 ? `(${reports.length})` : ""}
-
- Details
{/* Turns tab */}
diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md
index f1a243262..bb5354744 100644
--- a/docs/architecture/app-design.md
+++ b/docs/architecture/app-design.md
@@ -614,6 +614,16 @@ full-bleed shell. Opening or closing the preview keeps the tabs and filter rail
aligned with the navigation. Key registration and full-detail routes retain the
standard page padding.
+### Run detail navigation
+
+Run detail tabs appear in this order: **Details**, **Turns**, **Conversation**,
+**Reports**, **Network**, **Tool Calls**, **Videos**, **Logs**. Conversation
+requires at least one turn; Network and Tool Calls require HAR data; Videos
+requires run, setup, or turn video data. Videos remains next to the diagnostic
+tabs, with Logs last. Reordering does not change the default selection: completed
+runs with turns open Turns, while other runs open Logs. Explicit tab routes and
+the selected attempt (`?runId=`) are preserved.
+
### Project scoping (selected project, no default)
The Portal mirrors the API's fail-fast model: it holds a **selected project** (never a default) and injects it as `?projectId=` on every scoped request.