);
diff --git a/ui/src/features/dag-runs/components/dag-run-details/__tests__/DAGRunHeader.test.tsx b/ui/src/features/dag-runs/components/dag-run-details/__tests__/DAGRunHeader.test.tsx
index b4486dab28..3c55031aa7 100644
--- a/ui/src/features/dag-runs/components/dag-run-details/__tests__/DAGRunHeader.test.tsx
+++ b/ui/src/features/dag-runs/components/dag-run-details/__tests__/DAGRunHeader.test.tsx
@@ -1,8 +1,14 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later
-import { act, fireEvent, render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
+import {
+ act,
+ createEvent,
+ fireEvent,
+ render,
+ screen,
+} from '@testing-library/react';
+import { MemoryRouter, useLocation } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { Status, StatusLabel } from '@/api/v1/schema';
import { RemoteNodeProvider } from '@/contexts/RemoteNodeContext';
@@ -17,7 +23,7 @@ vi.mock('@/contexts/ConfigContext', () => ({
}));
vi.mock('../../common', () => ({
- DAGRunActions: () => null,
+ DAGRunActions: () => ,
}));
vi.mock('@/components/ui/matrix-text', () => ({
@@ -63,4 +69,191 @@ describe('DAGRunHeader', () => {
screen.queryByText('Run link copied to clipboard')
).not.toBeInTheDocument();
});
+
+ // The workflow page has no address for a sub-run: its link opens the root run.
+ it('names the root run on the copy link of a sub-run', () => {
+ render(
+
+
+
+
+
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Copy link to the root run' })
+ ).toBeInTheDocument();
+ });
+
+ // The run page has the same one-row header as the workflow page.
+ it('puts the name, the status icon, the run facts and the actions on one row', () => {
+ render(
+
+
+
+
+
+ );
+
+ const row = screen
+ .getByRole('heading', { name: 'nightly' })
+ .closest('div.flex-wrap')!;
+ expect(row).toHaveClass('@3xl:flex-nowrap');
+ expect(row).toContainElement(
+ screen.getByRole('img', { name: 'Succeeded' })
+ );
+ expect(row).toContainElement(screen.getByTitle('Run ID'));
+ // Stop and Retry follow the run ID.
+ const actions = screen.getByTestId('actions');
+ expect(actions.parentElement).toContainElement(screen.getByTitle('Run ID'));
+ });
+
+ it('names the status in text next to its icon', () => {
+ render(
+
+
+
+
+
+ );
+ expect(screen.getByText('Succeeded')).toBeVisible();
+ });
+
+ it('keeps the workspace scope on the links out of the page', () => {
+ render(
+
+
+
+
+
+ );
+ expect(
+ screen.getByRole('link', { name: 'Show in workflow' })
+ ).toHaveAttribute(
+ 'href',
+ '/dags/nightly.yaml?remoteNode=edge&workspace=ops&run=run-42'
+ );
+ expect(
+ screen.getByRole('link', { name: 'View workflow definition' })
+ ).toHaveAttribute(
+ 'href',
+ '/dags/nightly.yaml?remoteNode=edge&workspace=ops'
+ );
+ });
+
+ // parity:runpage-header
+ // parity:run-header-title-copy
+ describe('Show in workflow', () => {
+ function renderHeader(run: Record) {
+ render(
+
+
+
+
+
+
+ );
+ }
+
+ function Location() {
+ const { pathname, search } = useLocation();
+ return {pathname + search};
+ }
+
+ it('opens a root run in the executions of its workflow', () => {
+ renderHeader({ ...dagRun, sourceFileName: 'nightly.yaml' });
+
+ const definition = screen.getByRole('link', {
+ name: 'View workflow definition',
+ });
+ const show = screen.getByRole('link', { name: 'Show in workflow' });
+ expect(show).toHaveAttribute(
+ 'href',
+ '/dags/nightly.yaml?remoteNode=edge&run=run-42'
+ );
+ // It sits next to the definition link.
+ expect(definition.nextElementSibling).toBe(show);
+ });
+
+ it('leaves a modified click on the definition link to the browser', () => {
+ renderHeader({ ...dagRun, sourceFileName: 'nightly.yaml' });
+ const before = screen.getByTestId('location').textContent;
+
+ const link = screen.getByRole('link', {
+ name: 'View workflow definition',
+ });
+ const event = createEvent.click(link, { ctrlKey: true });
+ fireEvent(link, event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(screen.getByTestId('location')).toHaveTextContent(before ?? '');
+ });
+
+ // A modified click follows the href, so the href must hold the base path.
+ it('puts the base path of the app in the links to the workflow', () => {
+ render(
+
+
+
+
+
+ );
+
+ expect(
+ screen.getByRole('link', { name: 'View workflow definition' })
+ ).toHaveAttribute('href', '/dagu/dags/nightly.yaml?remoteNode=edge');
+ expect(
+ screen.getByRole('link', { name: 'Show in workflow' })
+ ).toHaveAttribute(
+ 'href',
+ '/dagu/dags/nightly.yaml?remoteNode=edge&run=run-42'
+ );
+ });
+
+ it('navigates inside the app on a plain click', () => {
+ renderHeader({ ...dagRun, sourceFileName: 'nightly.yaml' });
+
+ fireEvent.click(screen.getByRole('link', { name: 'Show in workflow' }));
+
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/dags/nightly.yaml?remoteNode=edge&run=run-42'
+ );
+ });
+
+ it('is absent for a sub-run', () => {
+ renderHeader({
+ ...dagRun,
+ name: 'child',
+ dagRunId: 'child-1',
+ sourceFileName: 'child.yaml',
+ });
+
+ expect(
+ screen.getByRole('link', { name: 'View workflow definition' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: 'Show in workflow' })
+ ).not.toBeInTheDocument();
+ });
+
+ it('is absent without a source file', () => {
+ renderHeader(dagRun);
+
+ expect(
+ screen.queryByRole('link', { name: 'Show in workflow' })
+ ).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/ui/src/features/dag-runs/hooks/__tests__/useExactDAGRuns.test.tsx b/ui/src/features/dag-runs/hooks/__tests__/useExactDAGRuns.test.tsx
deleted file mode 100644
index 801efd8c52..0000000000
--- a/ui/src/features/dag-runs/hooks/__tests__/useExactDAGRuns.test.tsx
+++ /dev/null
@@ -1,380 +0,0 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { useExactDAGRuns, type DAGRunListQuery } from '../dagRunPagination';
-
-const getMock = vi.fn();
-const client = {
- GET: getMock,
-};
-const { useDAGRunsListSSEMock } = vi.hoisted(() => ({
- useDAGRunsListSSEMock: vi.fn(() => ({
- data: null,
- error: null,
- isConnected: false,
- isConnecting: false,
- shouldUseFallback: true,
- })),
-}));
-
-vi.mock('@/hooks/api', () => ({
- useClient: () => client,
- useQuery: vi.fn(),
-}));
-
-vi.mock('@/hooks/useDAGRunsListSSE', () => ({
- useDAGRunsListSSE: useDAGRunsListSSEMock,
-}));
-
-function createQuery(
- overrides: Partial = {}
-): DAGRunListQuery {
- return {
- fromDate: 100,
- ...overrides,
- };
-}
-
-function createDeferred() {
- let resolve!: (value: T) => void;
- let reject!: (reason?: unknown) => void;
- const promise = new Promise((res, rej) => {
- resolve = res;
- reject = rej;
- });
- return { promise, resolve, reject };
-}
-
-describe('useExactDAGRuns', () => {
- beforeEach(() => {
- getMock.mockReset();
- useDAGRunsListSSEMock.mockClear();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('does not refetch when rerendered with an equivalent inline query object', async () => {
- getMock.mockResolvedValue({ data: { dagRuns: [] } });
-
- const { rerender } = renderHook(({ query }) => useExactDAGRuns({ query }), {
- initialProps: { query: createQuery() },
- });
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(1);
- });
-
- rerender({ query: createQuery() });
-
- await act(async () => {
- await Promise.resolve();
- });
-
- expect(getMock).toHaveBeenCalledTimes(1);
- });
-
- it('refetches exactly once when the semantic query changes', async () => {
- getMock.mockResolvedValue({ data: { dagRuns: [] } });
-
- const { rerender } = renderHook(({ query }) => useExactDAGRuns({ query }), {
- initialProps: { query: createQuery() },
- });
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(1);
- });
-
- rerender({ query: createQuery({ fromDate: 200, name: 'billing' }) });
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(2);
- });
-
- expect(getMock.mock.calls[1]?.[1]).toMatchObject({
- params: {
- query: expect.objectContaining({
- fromDate: 200,
- name: 'billing',
- remoteNode: 'local',
- }),
- },
- });
- });
-
- it('refresh uses the latest query after rerender instead of a stale closure', async () => {
- getMock.mockResolvedValue({ data: { dagRuns: [] } });
-
- const { result, rerender } = renderHook(
- ({ query }) => useExactDAGRuns({ query }),
- {
- initialProps: { query: createQuery({ fromDate: 100 }) },
- }
- );
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(1);
- });
-
- rerender({ query: createQuery({ fromDate: 300, name: 'ops' }) });
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(2);
- });
-
- await act(async () => {
- await result.current.refresh();
- });
-
- expect(getMock).toHaveBeenCalledTimes(3);
- expect(getMock.mock.calls[2]?.[1]).toMatchObject({
- params: {
- query: expect.objectContaining({
- fromDate: 300,
- name: 'ops',
- remoteNode: 'local',
- }),
- },
- });
- });
-
- it('ignores stale responses after a query change and aborts the older request', async () => {
- const firstRequest = createDeferred<{
- data: { dagRuns: Array<{ name: string; dagRunId: string }> };
- }>();
- const secondRequest = createDeferred<{
- data: { dagRuns: Array<{ name: string; dagRunId: string }> };
- }>();
-
- getMock
- .mockImplementationOnce(
- (_path: string, request?: { signal?: AbortSignal }) => {
- return firstRequest.promise.then((value) => {
- if (request?.signal?.aborted) {
- throw new DOMException('Aborted', 'AbortError');
- }
- return value;
- });
- }
- )
- .mockImplementationOnce(
- (_path: string, request?: { signal?: AbortSignal }) => {
- return secondRequest.promise.then((value) => {
- if (request?.signal?.aborted) {
- throw new DOMException('Aborted', 'AbortError');
- }
- return value;
- });
- }
- );
-
- const { result, rerender } = renderHook(
- ({ query }) => useExactDAGRuns({ query }),
- {
- initialProps: { query: createQuery({ fromDate: 100 }) },
- }
- );
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(1);
- });
-
- const firstSignal = getMock.mock.calls[0]?.[1]?.signal as AbortSignal;
-
- rerender({ query: createQuery({ fromDate: 400, name: 'new-dag' }) });
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(2);
- });
-
- expect(firstSignal.aborted).toBe(true);
-
- firstRequest.resolve({
- data: {
- dagRuns: [{ name: 'stale', dagRunId: 'old' }],
- },
- });
- secondRequest.resolve({
- data: {
- dagRuns: [{ name: 'fresh', dagRunId: 'new' }],
- },
- });
-
- await waitFor(() => {
- expect(result.current.data).toEqual([{ name: 'fresh', dagRunId: 'new' }]);
- });
- });
-
- it('streams the first page before pagination completes and uses smaller page limits', async () => {
- const firstPage = createDeferred<{
- data: {
- dagRuns: Array<{ name: string; dagRunId: string }>;
- nextCursor: string;
- };
- }>();
- const secondPage = createDeferred<{
- data: { dagRuns: Array<{ name: string; dagRunId: string }> };
- }>();
-
- getMock
- .mockImplementationOnce(() => firstPage.promise)
- .mockImplementationOnce(() => secondPage.promise);
-
- const { result } = renderHook(() =>
- useExactDAGRuns({ query: createQuery() })
- );
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(1);
- });
-
- expect(getMock.mock.calls[0]?.[1]).toMatchObject({
- params: {
- query: expect.objectContaining({
- remoteNode: 'local',
- fromDate: 100,
- limit: 100,
- }),
- },
- });
- expect(result.current.isLoading).toBe(true);
-
- firstPage.resolve({
- data: {
- dagRuns: [{ name: 'first', dagRunId: 'run-1' }],
- nextCursor: 'cursor-1',
- },
- });
-
- await waitFor(() => {
- expect(result.current.data).toEqual([
- { name: 'first', dagRunId: 'run-1' },
- ]);
- });
-
- expect(result.current.isLoading).toBe(false);
- expect(result.current.isValidating).toBe(true);
-
- await waitFor(() => {
- expect(getMock).toHaveBeenCalledTimes(2);
- });
-
- expect(getMock.mock.calls[1]?.[1]).toMatchObject({
- params: {
- query: expect.objectContaining({
- remoteNode: 'local',
- fromDate: 100,
- limit: 100,
- cursor: 'cursor-1',
- }),
- },
- });
-
- secondPage.resolve({
- data: {
- dagRuns: [{ name: 'second', dagRunId: 'run-2' }],
- },
- });
-
- await waitFor(() => {
- expect(result.current.data).toEqual([
- { name: 'first', dagRunId: 'run-1' },
- { name: 'second', dagRunId: 'run-2' },
- ]);
- });
-
- expect(result.current.isValidating).toBe(false);
- });
-
- it('polls only on the configured fallback interval instead of every rerender', async () => {
- vi.useFakeTimers();
- getMock.mockResolvedValue({ data: { dagRuns: [] } });
-
- renderHook(() =>
- useExactDAGRuns({
- query: createQuery(),
- fallbackIntervalMs: 2000,
- })
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(getMock).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(1999);
- });
- expect(getMock).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(1);
- });
- expect(getMock).toHaveBeenCalledTimes(2);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
- });
- expect(getMock).toHaveBeenCalledTimes(3);
- });
-
- it('does not keep polling while the DAG-runs SSE topic is healthy', async () => {
- vi.useFakeTimers();
- getMock.mockResolvedValue({ data: { dagRuns: [] } });
- useDAGRunsListSSEMock.mockReturnValue({
- data: null,
- error: null,
- isConnected: true,
- isConnecting: false,
- shouldUseFallback: false,
- });
-
- renderHook(() =>
- useExactDAGRuns({
- query: createQuery(),
- fallbackIntervalMs: 2000,
- })
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(getMock).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(6000);
- });
-
- expect(getMock).toHaveBeenCalledTimes(1);
- });
-
- it('keeps polling while the DAG-runs SSE topic is still connecting', async () => {
- vi.useFakeTimers();
- getMock.mockResolvedValue({ data: { dagRuns: [] } });
- useDAGRunsListSSEMock.mockReturnValue({
- data: null,
- error: null,
- isConnected: false,
- isConnecting: true,
- shouldUseFallback: false,
- });
-
- renderHook(() =>
- useExactDAGRuns({
- query: createQuery(),
- fallbackIntervalMs: 2000,
- })
- );
-
- await act(async () => {
- await Promise.resolve();
- });
- expect(getMock).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(2000);
- });
-
- expect(getMock).toHaveBeenCalledTimes(2);
- });
-});
diff --git a/ui/src/features/dag-runs/lib/__tests__/dagRunUrls.test.ts b/ui/src/features/dag-runs/lib/__tests__/dagRunUrls.test.ts
index bd2679da70..a1db07bbc9 100644
--- a/ui/src/features/dag-runs/lib/__tests__/dagRunUrls.test.ts
+++ b/ui/src/features/dag-runs/lib/__tests__/dagRunUrls.test.ts
@@ -2,20 +2,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import { describe, expect, it } from 'vitest';
-import { buildDAGPageURL, buildDAGRunPageURL } from '../dagRunUrls';
+import {
+ buildDAGPageURL,
+ buildDAGRunPageURL,
+ buildRunningTaskURL,
+} from '../dagRunUrls';
+// parity:common-url-builders
describe('dagRunUrls', () => {
- it('keeps remote scope in root DAG-run URLs', () => {
+ it('opens a root DAG-run on its workflow page', () => {
expect(
buildDAGRunPageURL({
rootDAGRunName: 'root dag',
rootDAGRunId: 'run 1',
remoteNode: 'edge-a',
})
- ).toBe('/dag-runs/root%20dag/run%201?remoteNode=edge-a');
+ ).toBe('/dags/root%20dag?remoteNode=edge-a&run=run+1');
});
- it('keeps the sub-DAG query tuple in DAG-run URLs', () => {
+ // The name of a DAG can differ from the name of its file.
+ it('opens the workflow file of the root run when the caller has it', () => {
+ expect(
+ buildDAGRunPageURL({
+ rootDAGRunName: 'etl',
+ rootDAGRunId: 'r1',
+ fileName: 'etl_file',
+ remoteNode: 'local',
+ })
+ ).toBe('/dags/etl_file?remoteNode=local&run=r1');
+ });
+
+ it('opens the root run for a sub-DAG run, as the workflow page has no sub-run address', () => {
expect(
buildDAGRunPageURL({
rootDAGRunName: 'root-dag',
@@ -23,11 +40,54 @@ describe('dagRunUrls', () => {
remoteNode: 'edge-a',
subDAGRunId: 'child-run',
})
+ ).toBe('/dags/root-dag?remoteNode=edge-a&run=root-run');
+ });
+
+ it('keeps the workspace, step and dock tab of a run URL', () => {
+ expect(
+ buildDAGRunPageURL({
+ rootDAGRunName: 'flow',
+ rootDAGRunId: 'r1',
+ remoteNode: 'local',
+ workspace: 'ops',
+ step: 'build',
+ dock: 'artifacts',
+ })
).toBe(
- '/dag-runs/root-dag/root-run?remoteNode=edge-a&subDAGRunId=child-run&dagRunId=root-run&dagRunName=root-dag'
+ '/dags/flow?remoteNode=local&workspace=ops&run=r1&step=build&dock=artifacts'
);
});
+ it('builds the run URL of a worker task from its root run', () => {
+ expect(
+ buildRunningTaskURL({ dagName: 'solo', dagRunId: 'r1' }, 'local')
+ ).toBe('/dags/solo?remoteNode=local&run=r1');
+ expect(
+ buildRunningTaskURL(
+ {
+ dagName: 'child',
+ dagRunId: 'c1',
+ parentDagRunName: 'mid',
+ parentDagRunId: 'm1',
+ rootDagRunName: 'top',
+ rootDagRunId: 't1',
+ },
+ 'local'
+ )
+ ).toBe('/dags/top?remoteNode=local&run=t1');
+ expect(
+ buildRunningTaskURL(
+ {
+ dagName: 'child',
+ dagRunId: 'c1',
+ parentDagRunName: 'mid',
+ parentDagRunId: 'm1',
+ },
+ 'local'
+ )
+ ).toBe('/dags/mid?remoteNode=local&run=m1');
+ });
+
it('keeps remote scope and sub-DAG context in DAG step log URLs', () => {
expect(
buildDAGPageURL({
@@ -43,4 +103,66 @@ describe('dagRunUrls', () => {
'/dags/workflow.yaml/log?remoteNode=edge-a&dagRunId=root-run&dagRunName=root-dag&subDAGRunId=child-run&step=build'
);
});
+
+ // Every call shape that existed before the workspace options keeps its output.
+ it.each([
+ [
+ { fileName: 'flow.yaml', remoteNode: 'local' },
+ '/dags/flow.yaml?remoteNode=local',
+ ],
+ [
+ { fileName: 'my flow', remoteNode: 'edge', tab: 'spec' },
+ '/dags/my%20flow/spec?remoteNode=edge',
+ ],
+ [
+ {
+ fileName: 'flow.yaml',
+ remoteNode: 'local',
+ tab: 'log',
+ rootDAGRunId: 'r1',
+ step: 'build',
+ stream: 'stderr',
+ },
+ '/dags/flow.yaml/log?remoteNode=local&dagRunId=r1&step=build&stream=stderr',
+ ],
+ [
+ { fileName: 'flow.yaml', remoteNode: 'local', step: 'build' },
+ '/dags/flow.yaml?remoteNode=local&step=build',
+ ],
+ ] as const)('keeps the output of %o', (input, url) => {
+ expect(buildDAGPageURL(input)).toBe(url);
+ });
+
+ it('selects a run with ?run in the workspace form', () => {
+ expect(
+ buildDAGPageURL({
+ fileName: 'flow.yaml',
+ remoteNode: 'edge',
+ run: 'run 1',
+ step: 'build',
+ dock: 'log',
+ })
+ ).toBe('/dags/flow.yaml?remoteNode=edge&run=run+1&step=build&dock=log');
+ });
+
+ it('has no mode: the run goes to the workflow route', () => {
+ expect(
+ buildDAGPageURL({
+ fileName: 'my flow',
+ remoteNode: 'local',
+ run: 'r1',
+ })
+ ).toBe('/dags/my%20flow?remoteNode=local&run=r1');
+ });
+
+ it('never writes dagRunId in the workspace form', () => {
+ const url = buildDAGPageURL({
+ fileName: 'flow.yaml',
+ remoteNode: 'local',
+ run: 'r1',
+ dock: 'log',
+ });
+ expect(url).toBe('/dags/flow.yaml?remoteNode=local&run=r1&dock=log');
+ expect(new URL(url, 'http://x').searchParams.has('dagRunId')).toBe(false);
+ });
});
diff --git a/ui/src/features/dag-runs/lib/dagRunUrls.ts b/ui/src/features/dag-runs/lib/dagRunUrls.ts
index a3797f8390..d788fec6c8 100644
--- a/ui/src/features/dag-runs/lib/dagRunUrls.ts
+++ b/ui/src/features/dag-runs/lib/dagRunUrls.ts
@@ -4,63 +4,105 @@
type DAGRunPageURLInput = {
rootDAGRunName: string;
rootDAGRunId: string;
+ /**
+ * The workflow file of the root run. A caller that has it passes it: the
+ * name of a DAG can differ from the name of its file.
+ */
+ fileName?: string;
remoteNode: string;
+ /** Workspace scope (?workspace) to keep on the target page. */
+ workspace?: string;
+ /**
+ * Accepted for the callers that hold a sub-run. The workflow page has no
+ * address for a sub-run, so the link opens its root run.
+ */
subDAGRunId?: string;
step?: string;
+ /** Dock tab, for example 'artifacts'. */
+ dock?: string;
};
-type DAGPageURLInput = {
+type LegacyDAGPageURLInput = {
fileName: string;
remoteNode: string;
+ workspace?: string;
tab?: string;
rootDAGRunName?: string;
rootDAGRunId?: string;
subDAGRunId?: string;
step?: string;
stream?: string;
+ run?: never;
+ dock?: never;
+ mode?: never;
};
+/** A workflow page URL in the form of the workspace: ?run selects the run, never ?dagRunId. */
+type WorkspaceDAGPageURLInput = {
+ fileName: string;
+ remoteNode: string;
+ workspace?: string;
+ run?: string;
+ step?: string;
+ /** Dock tab, for example 'log'. */
+ dock?: string;
+ tab?: never;
+ rootDAGRunName?: never;
+ rootDAGRunId?: never;
+ subDAGRunId?: never;
+ stream?: never;
+};
+
+type DAGPageURLInput = LegacyDAGPageURLInput | WorkspaceDAGPageURLInput;
+
function appendQuery(path: string, params: URLSearchParams): string {
const query = params.toString();
return query ? `${path}?${query}` : path;
}
+/**
+ * The URL of a run: the workflow page of the root run with ?run. Callers that
+ * hold only a run list row pass its name and id as the root.
+ */
export function buildDAGRunPageURL({
rootDAGRunName,
rootDAGRunId,
+ fileName,
remoteNode,
- subDAGRunId,
+ workspace,
step,
+ dock,
}: DAGRunPageURLInput): string {
- const params = new URLSearchParams();
- params.set('remoteNode', remoteNode);
- if (subDAGRunId) {
- params.set('subDAGRunId', subDAGRunId);
- params.set('dagRunId', rootDAGRunId);
- params.set('dagRunName', rootDAGRunName);
- }
- if (step) {
- params.set('step', step);
- }
-
- return appendQuery(
- `/dag-runs/${encodeURIComponent(rootDAGRunName)}/${encodeURIComponent(rootDAGRunId)}`,
- params
- );
+ return buildWorkspaceDAGPageURL({
+ fileName: fileName ?? rootDAGRunName,
+ remoteNode,
+ workspace,
+ run: rootDAGRunId,
+ step,
+ dock,
+ });
}
-export function buildDAGPageURL({
- fileName,
- remoteNode,
- tab,
- rootDAGRunName,
- rootDAGRunId,
- subDAGRunId,
- step,
- stream,
-}: DAGPageURLInput): string {
+export function buildDAGPageURL(input: DAGPageURLInput): string {
+ if (input.run || input.dock) {
+ return buildWorkspaceDAGPageURL(input as WorkspaceDAGPageURLInput);
+ }
+ const {
+ fileName,
+ remoteNode,
+ workspace,
+ tab,
+ rootDAGRunName,
+ rootDAGRunId,
+ subDAGRunId,
+ step,
+ stream,
+ } = input as LegacyDAGPageURLInput;
const params = new URLSearchParams();
params.set('remoteNode', remoteNode);
+ if (workspace) {
+ params.set('workspace', workspace);
+ }
if (rootDAGRunId) {
params.set('dagRunId', rootDAGRunId);
}
@@ -82,3 +124,96 @@ export function buildDAGPageURL({
: `/dags/${encodeURIComponent(fileName)}`;
return appendQuery(path, params);
}
+
+function buildWorkspaceDAGPageURL({
+ fileName,
+ remoteNode,
+ workspace,
+ run,
+ step,
+ dock,
+}: WorkspaceDAGPageURLInput): string {
+ const params = new URLSearchParams();
+ params.set('remoteNode', remoteNode);
+ if (workspace) {
+ params.set('workspace', workspace);
+ }
+ if (run) {
+ params.set('run', run);
+ }
+ if (step) {
+ params.set('step', step);
+ }
+ if (dock) {
+ params.set('dock', dock);
+ }
+ const path = `/dags/${encodeURIComponent(fileName)}`;
+ return appendQuery(path, params);
+}
+
+/** The status tabs of the old run page that the Run panel of the workspace has as dock tabs. */
+const LEGACY_RUN_TAB_DOCK: Readonly> = {
+ timeline: 'timeline',
+ outputs: 'outputs',
+ artifacts: 'artifacts',
+ agent: 'agent',
+ chat: 'chat',
+ tasks: 'tasks',
+ approval: 'actions',
+ 'human-tasks': 'actions',
+};
+
+/**
+ * The workflow page URL for a link to the removed run page
+ * /dag-runs/:name/:dagRunId. A sub-run link (?subDAGRunId with the root run
+ * in ?dagRunId and ?dagRunName) opens its root run. remoteNode and workspace
+ * stay; the old ?tab becomes ?dock.
+ */
+export function legacyRunPageRedirectURL(
+ name: string,
+ dagRunId: string,
+ search: URLSearchParams,
+ fallbackRemoteNode: string
+): string {
+ const subDAGRunId = search.get('subDAGRunId');
+ const rootId = subDAGRunId ? search.get('dagRunId') : null;
+ const rootName = subDAGRunId ? search.get('dagRunName') : null;
+ const runId = rootId || dagRunId;
+ const tab = search.get('tab');
+ return buildWorkspaceDAGPageURL({
+ fileName: rootName || name,
+ remoteNode: search.get('remoteNode')?.trim() || fallbackRemoteNode,
+ workspace: search.get('workspace') || undefined,
+ run: runId === 'latest' ? undefined : runId,
+ step: search.get('step') || undefined,
+ dock: (tab && LEGACY_RUN_TAB_DOCK[tab]) || undefined,
+ });
+}
+
+type RunningTaskLike = {
+ dagName: string;
+ dagRunId: string;
+ rootDagRunName?: string;
+ rootDagRunId?: string;
+ parentDagRunName?: string;
+ parentDagRunId?: string;
+};
+
+/** The URL of the run of a worker task. A child task opens its root run, or its parent when the root is not known. */
+export function buildRunningTaskURL(
+ task: RunningTaskLike,
+ remoteNode: string
+): string {
+ const isChild = Boolean(task.parentDagRunName && task.parentDagRunId);
+ const rootName =
+ (isChild ? task.rootDagRunName || task.parentDagRunName : undefined) ||
+ task.dagName;
+ const rootId =
+ (isChild ? task.rootDagRunId || task.parentDagRunId : undefined) ||
+ task.dagRunId;
+ return buildDAGRunPageURL({
+ rootDAGRunName: rootName,
+ rootDAGRunId: rootId,
+ remoteNode,
+ });
+}
diff --git a/ui/src/features/dags/components/__tests__/DAGStatus.test.tsx b/ui/src/features/dags/components/__tests__/DAGStatus.test.tsx
index c29ef6b15f..e93d9e0843 100644
--- a/ui/src/features/dags/components/__tests__/DAGStatus.test.tsx
+++ b/ui/src/features/dags/components/__tests__/DAGStatus.test.tsx
@@ -876,7 +876,7 @@ describe('DAGStatus', () => {
expect(screen.getByText('Node status table')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Open run/ })).toHaveAttribute(
'href',
- `/dag-runs/${dagRun.name}/${dagRun.dagRunId}?remoteNode=local`
+ `/dags/${dagRun.name}?remoteNode=local&run=${dagRun.dagRunId}`
);
});
diff --git a/ui/src/features/dags/components/common/DAGPagination.tsx b/ui/src/features/dags/components/common/DAGPagination.tsx
index 4258d5a637..ecebf0db18 100644
--- a/ui/src/features/dags/components/common/DAGPagination.tsx
+++ b/ui/src/features/dags/components/common/DAGPagination.tsx
@@ -313,7 +313,7 @@ const DAGPagination = ({
{/* Opens on hover, and on keyboard focus anywhere inside it. */}
-
+
{[10, 25, 50, 100, 200].map((limit) => (
{
- const interval = setInterval(() => {
- setFrame((prev) => (prev + 1) % BRAILLE_FRAMES.length);
- }, 80);
- return () => clearInterval(interval);
- }, []);
+const BRAILLE_FRAME_MS = 80;
+// The frame is read from the shared clock, so that all spinners show the same
+// frame, on one timer, and none runs while the tab is hidden. It stays on the
+// first frame when the user asks for reduced motion.
+function BrailleSpinner() {
+ const reducedMotion = usePrefersReducedMotion();
+ const frame = useClock(reducedMotion ? null : BRAILLE_FRAME_MS, (now) =>
+ reducedMotion
+ ? 0
+ : Math.floor(now / BRAILLE_FRAME_MS) % BRAILLE_FRAMES.length
+ );
return <>{BRAILLE_FRAMES[frame]}>;
}
diff --git a/ui/src/features/dags/components/common/__tests__/NodeStatusChip.test.tsx b/ui/src/features/dags/components/common/__tests__/NodeStatusChip.test.tsx
new file mode 100644
index 0000000000..7da16aa1dc
--- /dev/null
+++ b/ui/src/features/dags/components/common/__tests__/NodeStatusChip.test.tsx
@@ -0,0 +1,57 @@
+// Copyright (C) 2026 Yota Hamada
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { act, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { NodeStatus, NodeStatusLabel } from '@/api/v1/schema';
+import NodeStatusChip from '../NodeStatusChip';
+
+function preferReducedMotion(matches: boolean) {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ });
+}
+
+// The spinner frame before and after `ms` of the clock.
+function spinnerFrames(ms: number): [string, string] {
+ const { container } = render(
+
+ {NodeStatusLabel.retrying}
+
+ );
+ const spinner = container.querySelector('[aria-hidden="true"]')!;
+ const before = spinner.textContent ?? '';
+ act(() => {
+ vi.advanceTimersByTime(ms);
+ });
+ return [before, spinner.textContent ?? ''];
+}
+
+describe('NodeStatusChip spinner', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('steps through its frames', () => {
+ preferReducedMotion(false);
+ const [before, after] = spinnerFrames(80);
+ expect(after).not.toBe(before);
+ });
+
+ it('stays still when the user asks for reduced motion', () => {
+ preferReducedMotion(true);
+ const [before, after] = spinnerFrames(80);
+ expect(after).toBe(before);
+ });
+});
diff --git a/ui/src/features/dags/components/common/__tests__/PushBackHistory.test.tsx b/ui/src/features/dags/components/common/__tests__/PushBackHistory.test.tsx
index df9ba44bba..964e623344 100644
--- a/ui/src/features/dags/components/common/__tests__/PushBackHistory.test.tsx
+++ b/ui/src/features/dags/components/common/__tests__/PushBackHistory.test.tsx
@@ -22,7 +22,7 @@ describe('PushBackHistory', () => {
expect(screen.getByText('5 minutes ago')).toHaveAttribute(
'title',
- dayjs(at).format('YYYY-MM-DD HH:mm:ss Z')
+ dayjs(at).format('YYYY-MM-DD HH:mm:ss')
);
});
diff --git a/ui/src/features/dags/components/dag-details/__tests__/DAGStatusOverview.test.tsx b/ui/src/features/dags/components/dag-details/__tests__/DAGStatusOverview.test.tsx
index df183a16d9..9308cfb40f 100644
--- a/ui/src/features/dags/components/dag-details/__tests__/DAGStatusOverview.test.tsx
+++ b/ui/src/features/dags/components/dag-details/__tests__/DAGStatusOverview.test.tsx
@@ -15,7 +15,7 @@ import {
import dayjs from '@/lib/dayjs';
import DAGStatusOverview from '../DAGStatusOverview';
-const ABSOLUTE_FORMAT = 'YYYY-MM-DD HH:mm:ss Z';
+const ABSOLUTE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
beforeEach(() => {
// Only Date is faked so relative labels stay stable without stalling timers.
diff --git a/ui/src/features/dags/components/dag-details/__tests__/NodeStatusTableRow.test.tsx b/ui/src/features/dags/components/dag-details/__tests__/NodeStatusTableRow.test.tsx
index 18b1e5575d..fe04880589 100644
--- a/ui/src/features/dags/components/dag-details/__tests__/NodeStatusTableRow.test.tsx
+++ b/ui/src/features/dags/components/dag-details/__tests__/NodeStatusTableRow.test.tsx
@@ -79,7 +79,7 @@ const stepLogPath = '/dag-runs/{name}/{dagRunId}/steps/{stepName}/log';
const NOW = '2026-07-22T01:05:00Z';
const FIVE_MINUTES_AGO = '2026-07-22T01:00:00Z';
-const ABSOLUTE_FORMAT = 'YYYY-MM-DD HH:mm:ss Z';
+const ABSOLUTE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
const timedNode = {
step: { name: 'build' },
@@ -201,7 +201,7 @@ describe('NodeStatusTableRow', () => {
for (const link of links) {
expect(link).toHaveAttribute(
'href',
- '/dag-runs/producer/run-2?remoteNode=worker-a'
+ '/dags/producer?remoteNode=worker-a&run=run-2'
);
}
});
diff --git a/ui/src/features/dags/components/dag-execution/__tests__/RunProgressModal.test.tsx b/ui/src/features/dags/components/dag-execution/__tests__/RunProgressModal.test.tsx
index 2ab74ab1f1..d0e5b3921b 100644
--- a/ui/src/features/dags/components/dag-execution/__tests__/RunProgressModal.test.tsx
+++ b/ui/src/features/dags/components/dag-execution/__tests__/RunProgressModal.test.tsx
@@ -169,7 +169,7 @@ describe('RunProgressModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Open graph step' }));
expect(screen.getByLabelText('Location')).toHaveTextContent(
- '/dag-runs/example/run-1?remoteNode=edge&step=build'
+ '/dags/example?remoteNode=edge&run=run-1&step=build'
);
});
@@ -187,7 +187,7 @@ describe('RunProgressModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'View details' }));
expect(screen.getByLabelText('Location')).toHaveTextContent(
- '/dag-runs/example/run-1?remoteNode=edge'
+ '/dags/example?remoteNode=edge&run=run-1'
);
});
@@ -205,7 +205,7 @@ describe('RunProgressModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Inspect step' }));
expect(screen.getByLabelText('Location')).toHaveTextContent(
- '/dag-runs/example/run-1?remoteNode=edge&step=build'
+ '/dags/example?remoteNode=edge&run=run-1&step=build'
);
});
});
diff --git a/ui/src/features/dags/components/step-details/PanelShell.tsx b/ui/src/features/dags/components/step-details/PanelShell.tsx
new file mode 100644
index 0000000000..ee8a5a5faa
--- /dev/null
+++ b/ui/src/features/dags/components/step-details/PanelShell.tsx
@@ -0,0 +1,171 @@
+// Copyright (C) 2026 Yota Hamada
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { X } from 'lucide-react';
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ type ReactElement,
+ type ReactNode,
+} from 'react';
+import { IconButton } from '@/components/ui/icon-button';
+
+/** The placement that every right-side panel shares. */
+export const PANEL_DOCK_GEOMETRY =
+ 'bottom-0 right-0 top-[var(--ndv-top,0px)] max-w-full border-l';
+
+/**
+ * Where the panels sit. 'window' fixes them to the window, under the bar that
+ * the host sets in `--ndv-top`. 'zone' puts them inside the nearest positioned
+ * ancestor, the graph zone of the workspace, and never beyond it. In a zone
+ * they start under its row of toolbars (`--ww-toolbar-row`).
+ */
+export type PanelPlacement = 'window' | 'zone';
+
+const PanelPlacementContext = createContext('window');
+
+/** A host gives 'zone' to the panels below it; without it they use the window. */
+export const PanelPlacementProvider = PanelPlacementContext.Provider;
+
+export function usePanelPlacement(): PanelPlacement {
+ return useContext(PanelPlacementContext);
+}
+
+// The important top wins over a host rule that starts a fixed panel below its bar.
+// The zone sets --ww-toolbar-row to the height of its row of toolbars (0 without
+// one). A panel in the zone is one box from the top of the zone: the first rows
+// of its padding are under the toolbars, which sit over it, so that the row and
+// the title bar have the one background of the panel.
+export const ZONE_PANEL_TOP = 'top-0! pt-(--ww-toolbar-row,0px)';
+const ZONE_DOCK_GEOMETRY = `bottom-0 right-0 ${ZONE_PANEL_TOP} max-w-full border-l`;
+
+const SHELL_BASE =
+ 'z-[60] flex flex-col border-border bg-card shadow-lg shadow-foreground/10';
+
+/** The edges of a docked panel for a placement. */
+export function panelDockGeometry(placement: PanelPlacement): string {
+ return placement === 'zone' ? ZONE_DOCK_GEOMETRY : PANEL_DOCK_GEOMETRY;
+}
+
+/**
+ * One geometry for every panel on the right of the page (Workflow, YAML, Add
+ * a step, Step details). The panel starts under the bar that the host sets in
+ * `--ndv-top` (0 without a host), ends at the bottom of the window, has the
+ * stored width, and scrolls its body, never itself.
+ */
+export const PANEL_SHELL_CLASS = `fixed ${SHELL_BASE} ${PANEL_DOCK_GEOMETRY}`;
+
+/** The same shell inside the graph zone: absolute, on the right, from under the row of toolbars to the bottom. */
+export const PANEL_ZONE_SHELL_CLASS = `absolute ${SHELL_BASE} ${ZONE_DOCK_GEOMETRY}`;
+
+/** The shell class of a panel for a placement. */
+export function panelShellClass(placement: PanelPlacement): string {
+ return placement === 'zone' ? PANEL_ZONE_SHELL_CLASS : PANEL_SHELL_CLASS;
+}
+
+/** The title bar: one height for every panel, the close button at its end. */
+export const PANEL_HEADER_CLASS =
+ 'flex h-12 shrink-0 items-center gap-2 border-b border-border bg-card px-3';
+
+/** The scrolling body under the title bar. */
+export const PANEL_BODY_CLASS = 'min-h-0 flex-1 overflow-auto p-4';
+
+/** Below this viewport width a panel never takes more than its share of the zone. */
+export const PANEL_CAP_VIEWPORT = 1100;
+/** The share of the zone that a panel may take on a narrow viewport. */
+export const PANEL_CAP_RATIO = 0.45;
+/** A capped panel keeps this width, so that its form stays usable. */
+export const PANEL_CAP_FLOOR = 360;
+
+function currentViewport(): number {
+ return typeof window === 'undefined' ? Infinity : window.innerWidth;
+}
+
+/**
+ * The CSS width of a panel: the stored width, never wider than the window.
+ * Below PANEL_CAP_VIEWPORT it is also capped at PANEL_CAP_RATIO of the zone
+ * (the nearest positioned ancestor), with PANEL_CAP_FLOOR as its minimum.
+ */
+export function panelWidth(
+ width: number,
+ viewport = currentViewport()
+): string {
+ if (viewport >= PANEL_CAP_VIEWPORT) return `min(100vw, ${width}px)`;
+ return `min(100vw, ${width}px, max(${PANEL_CAP_RATIO * 100}%, ${PANEL_CAP_FLOOR}px))`;
+}
+
+/** The max-width of a panel that sets its own width; undefined when no cap applies. */
+export function panelMaxWidth(
+ viewport = currentViewport()
+): string | undefined {
+ if (viewport >= PANEL_CAP_VIEWPORT) return undefined;
+ return `max(${PANEL_CAP_RATIO * 100}%, ${PANEL_CAP_FLOOR}px)`;
+}
+
+/**
+ * The width in pixels that a panel takes in a zone of `zoneWidth`, as
+ * panelWidth computes it in CSS. A host that shrinks its content beside the
+ * panel reads this value.
+ */
+export function effectivePanelWidth(
+ width: number,
+ zoneWidth: number,
+ viewport = currentViewport()
+): number {
+ const limit = Math.min(width, viewport);
+ if (viewport >= PANEL_CAP_VIEWPORT) return limit;
+ return Math.round(
+ Math.min(limit, Math.max(PANEL_CAP_RATIO * zoneWidth, PANEL_CAP_FLOOR))
+ );
+}
+
+/** The viewport width that tracks resizes, for the cap of panelWidth. */
+export function useViewportWidth(): number {
+ const [width, setWidth] = useState(() =>
+ typeof window === 'undefined' ? PANEL_CAP_VIEWPORT : window.innerWidth
+ );
+ useEffect(() => {
+ const update = () => setWidth(window.innerWidth);
+ window.addEventListener('resize', update);
+ return () => window.removeEventListener('resize', update);
+ }, []);
+ return width;
+}
+
+/** The title bar of a panel. `children` replaces the title (the YAML bar). */
+export function PanelHeader({
+ title,
+ subtitle,
+ closeLabel,
+ onClose,
+ children,
+}: {
+ title?: ReactNode;
+ subtitle?: ReactNode;
+ closeLabel: string;
+ onClose(): void;
+ children?: ReactNode;
+}): ReactElement {
+ return (
+
+ {children ?? (
+
+
+ {title}
+
+ {subtitle && (
+
{subtitle}
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/features/dags/components/step-details/StepDetails.tsx b/ui/src/features/dags/components/step-details/StepDetails.tsx
index 85e9776367..ae46e4f5de 100644
--- a/ui/src/features/dags/components/step-details/StepDetails.tsx
+++ b/ui/src/features/dags/components/step-details/StepDetails.tsx
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import BorderedBox from '@/components/ui/bordered-box';
import { getLogMessageFromConfig } from '@/lib/executor-utils';
import { getHarnessStepSummary } from '@/lib/harness-step';
+import { SYNTAX_CLASS } from '@/lib/syntax-colors';
import React from 'react';
import { LogStepMessage } from '../dag-details/LogStepMessage';
import { I18nText } from '@/i18n/I18nText';
@@ -293,17 +294,17 @@ function ShellCodeBlock({ value }: { value: string }) {
const lines = value.split('\n');
return (
-
-